diff --git a/.gitignore b/.gitignore index e36ce56..33b47b2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,10 @@ *.pyc __pycache__/ local_settings.py -*.sqlite3 -db.sqlite3-journal -media staticfiles_collected/ +*.sqlite3 +*.sqlite3-journal +media_files/ # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # in your Git repository. Update and uncomment the following line accordingly. @@ -177,10 +177,9 @@ cython_debug/ # My -# VS Code .vscode/ *.code-workspace /logs/ -all.txt +*.txt # End of My \ No newline at end of file diff --git a/GithubAquarium/asgi.py b/GithubAquarium/asgi.py deleted file mode 100644 index 1258dfd..0000000 --- a/GithubAquarium/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for GithubAquarium project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'GithubAquarium.settings') - -application = get_asgi_application() diff --git a/GithubAquarium/settings.py b/GithubAquarium/settings.py index b6622e6..e43d37a 100644 --- a/GithubAquarium/settings.py +++ b/GithubAquarium/settings.py @@ -31,6 +31,15 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env('DEBUG') + +# --- Custom Service Settings --- +# SVG 렌더링 시 이미지 절대 경로 생성을 위한 도메인 설정 +if DEBUG: + SITE_DOMAIN = 'http://localhost:8000' +else: + # 실제 운영 도메인 (Nginx가 처리할 도메인) + SITE_DOMAIN = 'https://githubaquarium.store' + # Allowed hosts for the application ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'githubaquarium.store', 'www.githubaquarium.store'] @@ -136,6 +145,10 @@ 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', + 'OPTIONS': { + # DB가 잠겨있으면 20초까지 대기하다가 풀리면 씁니다. + 'timeout': 20, + } } } @@ -351,4 +364,9 @@ 'timeout': 6000, # sec 'retry': 36000, 'orm': 'default', # temporary use ORM as broker -} \ No newline at end of file +} + +# --- Game Logic Settings --- +DEFAULT_FISH_GROUP = "ShrimpWich" + +RENDER_DOMAIN = "http://localhost:8000" if DEBUG else "https://githubaquarium.store" \ No newline at end of file diff --git a/GithubAquarium/urls.py b/GithubAquarium/urls.py index 41a4417..e219f3b 100644 --- a/GithubAquarium/urls.py +++ b/GithubAquarium/urls.py @@ -61,7 +61,7 @@ # --- Local App APIs --- # Include URL configurations from the 'repositories' and 'users' apps path('api/repositories/', include('apps.repositories.urls')), - path('api/users/', include('apps.users.urls')), + # path('api/users/', include('apps.users.urls')), path('api/aquatics/', include('apps.aquatics.urls')), path('api/shop/', include('apps.shop.urls')), diff --git a/GithubAquarium/views.py b/GithubAquarium/views.py index 0293bd3..b16288e 100644 --- a/GithubAquarium/views.py +++ b/GithubAquarium/views.py @@ -1,20 +1,42 @@ +# GithubAquarium/views.py from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter from allauth.socialaccount.providers.oauth2.client import OAuth2Client from dj_rest_auth.registration.views import SocialLoginView from django.conf import settings from rest_framework.permissions import AllowAny +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi class GitHubLogin(SocialLoginView): """ - Custom view for handling GitHub social login. - - This view integrates with django-allauth and dj-rest-auth to perform - social authentication using a GitHub account. It specifies the adapter, - callback URL, and client class necessary for the OAuth2 flow. + GitHub OAuth2를 이용한 소셜 로그인/회원가입을 처리합니다. """ adapter_class = GitHubOAuth2Adapter callback_url = settings.GITHUB_CALLBACK_URL client_class = OAuth2Client - # [추가] 이 뷰는 로그인 전이므로 누구나 접근 가능해야 함 permission_classes = (AllowAny,) authentication_classes = [] + + @swagger_auto_schema( + operation_summary="GitHub 소셜 로그인", + operation_description="GitHub에서 받은 access_token을 전달하여 JWT 토큰을 발급받습니다.", + tags=["Authentication"], + responses={ + 200: openapi.Response( + description="로그인 성공. access 및 refresh 토큰이 쿠키 또는 바디로 반환됩니다.", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'access': openapi.Schema(type=openapi.TYPE_STRING), + 'refresh': openapi.Schema(type=openapi.TYPE_STRING), + 'user': openapi.Schema(type=openapi.TYPE_OBJECT, properties={ + 'pk': openapi.Schema(type=openapi.TYPE_INTEGER), + 'username': openapi.Schema(type=openapi.TYPE_STRING), + }) + } + ) + ) + } + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) \ No newline at end of file diff --git a/GithubAquarium/webhook_views.py b/GithubAquarium/webhook_views.py index c131dfb..52bbd1e 100644 --- a/GithubAquarium/webhook_views.py +++ b/GithubAquarium/webhook_views.py @@ -1,70 +1,28 @@ # GithubAquarium/webhook_views.py import hashlib import hmac -import logging # Import the logging module from django.conf import settings from django_q.tasks import async_task from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status - - from drf_yasg.utils import swagger_auto_schema -# Get an instance of a logger for this module -logger = logging.getLogger(__name__) - class GitHubWebhookView(APIView): - """ - Receives and processes webhook events from GitHub. - - This view verifies the integrity of the request using the webhook secret, - then processes 'star', 'push', and 'meta' events to keep the database - in sync with GitHub. - """ - @swagger_auto_schema( - summary="GitHub Webhook Handler", - description=""" - Handles incoming webhook events from GitHub to keep the application's database in sync. - - ### Supported Events: - - **star**: Triggered when a repository is starred or unstarred. Updates the `stargazers_count`. - - **push**: Triggered on a push to a repository. Updates repository details and records new commits. - - **meta**: Triggered for webhook administrative events (e.g., deletion). - - The request body should be the raw JSON payload from the GitHub webhook, and the `X-Hub-Signature-256` header must be present for verification. - """, - operation_id="handle_github_webhook", + operation_summary="GitHub 웹훅 수신", + operation_description="GitHub로부터 push, star 이벤트를 수신하여 데이터를 비동기로 동기화합니다.", tags=["Webhooks"], + responses={200: "수신 완료 및 큐 등록", 403: "서명 불일치"} ) def post(self, request, *args, **kwargs): - # 1. Verify Signature (보안 검증은 동기로 즉시 처리해야 함) signature_header = request.headers.get('X-Hub-Signature-256') if not signature_header: - return Response({'detail': 'Signature header missing'}, status=status.HTTP_403_FORBIDDEN) - - sha_name, signature = signature_header.split('=', 1) - if sha_name != 'sha256': - return Response({'detail': 'Invalid signature format'}, status=status.HTTP_403_FORBIDDEN) - + return Response({'detail': 'Signature missing'}, status=403) mac = hmac.new(settings.GITHUB_WEBHOOK_SECRET.encode('utf-8'), msg=request.body, digestmod=hashlib.sha256) - if not hmac.compare_digest(mac.hexdigest(), signature): - return Response({'detail': 'Invalid signature'}, status=status.HTTP_403_FORBIDDEN) + if not hmac.compare_digest(f"sha256={mac.hexdigest()}", signature_header): + return Response({'detail': 'Invalid signature'}, status=403) - # 2. 작업 Queue에 등록 event_type = request.headers.get('X-GitHub-Event') - payload = request.data - - # 비동기 Task 호출 - async_task( - 'apps.repositories.tasks.process_webhook_event_task', # Task 함수 경로 - event_type, - payload, - group='webhooks' # 그룹을 지정하면 나중에 관리하기 편함 - ) - - logger.info(f"Webhook event '{event_type}' queued for processing.") - - # 3. GitHub에 즉시 성공 응답 반환 - return Response({'detail': 'Event queued'}, status=status.HTTP_200_OK) \ No newline at end of file + async_task('apps.repositories.tasks.process_webhook_event_task', event_type, request.data) + return Response({'detail': 'Event queued'}, status=200) \ No newline at end of file diff --git a/apps/aquatics/admin.py b/apps/aquatics/admin.py index e9e3e7f..283b2ee 100644 --- a/apps/aquatics/admin.py +++ b/apps/aquatics/admin.py @@ -1,37 +1,41 @@ from django.contrib import admin -from .models import UnlockedFish, OwnBackground, Aquarium, Fishtank, ContributionFish, FishtankSetting +from .models import UnlockedFish, OwnBackground, Aquarium, Fishtank, ContributionFish @admin.register(UnlockedFish) class UnlockedFishAdmin(admin.ModelAdmin): list_display = ('user', 'fish_species', 'unlocked_at') - list_filter = ('user', 'fish_species') + list_filter = ('unlocked_at', 'fish_species__rarity') search_fields = ('user__username', 'fish_species__name') @admin.register(OwnBackground) class OwnBackgroundAdmin(admin.ModelAdmin): list_display = ('user', 'background', 'unlocked_at') - list_filter = ('user', 'background') search_fields = ('user__username', 'background__name') @admin.register(Aquarium) class AquariumAdmin(admin.ModelAdmin): - list_display = ('user', 'background', 'svg_path') - search_fields = ('user__username',) + list_display = ('user', 'background', 'fish_count') + + def fish_count(self, obj): + return obj.fishes.count() @admin.register(Fishtank) class FishtankAdmin(admin.ModelAdmin): - list_display = ('repository', 'svg_path') - search_fields = ('repository__name',) + list_display = ('repository', 'fish_count') + + def fish_count(self, obj): + return obj.repository.contributors.count() @admin.register(ContributionFish) class ContributionFishAdmin(admin.ModelAdmin): - # list_display 및 list_filter 수정 - list_display = ('contributor', 'fish_species', 'aquarium', 'is_visible_in_fishtank', 'is_visible_in_aquarium') - list_filter = ('is_visible_in_fishtank', 'is_visible_in_aquarium', 'fish_species') - search_fields = ('contributor__user__username', 'fish_species__name') - -@admin.register(FishtankSetting) -class FishtankSettingAdmin(admin.ModelAdmin): - list_display = ('fishtank', 'contributor', 'background') - list_filter = ('fishtank', 'contributor') - search_fields = ('fishtank__repository__name', 'contributor__username') \ No newline at end of file + list_display = ('id', 'get_user', 'get_repo', 'fish_species', 'is_visible_in_fishtank', 'is_visible_in_aquarium') + list_filter = ('is_visible_in_fishtank', 'is_visible_in_aquarium', 'fish_species__maturity') + search_fields = ('contributor__user__username', 'contributor__repository__full_name') + + def get_user(self, obj): + return obj.contributor.user.username + get_user.short_description = 'User' + + def get_repo(self, obj): + return obj.contributor.repository.name + get_repo.short_description = 'Repository' diff --git a/apps/aquatics/logic.py b/apps/aquatics/logic.py new file mode 100644 index 0000000..2de52ab --- /dev/null +++ b/apps/aquatics/logic.py @@ -0,0 +1,83 @@ +# apps/aquatics/logic.py +import logging +import random +from django.conf import settings +from apps.items.models import FishSpecies +from apps.aquatics.models import ContributionFish, UnlockedFish, Aquarium + +logger = logging.getLogger(__name__) + +def update_or_create_contribution_fish(contributor): + """ + 기여자의 커밋 수에 맞춰 물고기 종을 결정하고 ContributionFish를 업데이트합니다. + + 1. 신규 획득 시: DB에 존재하는 물고기 그룹 중 하나를 '랜덤'으로 배정합니다. + 2. 기존 보유 시: 이미 배정받은 그룹 내에서 진화(Maturity 증가)만 수행합니다. + 3. 획득한 물고기는 자동으로 유저의 아쿠아리움에 배치됩니다. + """ + commit_count = contributor.commit_count + user = contributor.user + + # 1. 물고기 그룹(Group Code) 결정 + # contributor에게 이미 할당된 물고기가 있는지 확인 (Related Object 참조) + current_fish = getattr(contributor, 'contribution_fish', None) + + if current_fish: + # 이미 물고기가 있다면, 기존 그룹을 유지 (진화만 함) + group_code = current_fish.fish_species.group_code + else: + # [수정] 신규 할당: 등록된 모든 물고기 그룹 중 하나를 랜덤 선택 + available_groups = list(FishSpecies.objects.values_list('group_code', flat=True).distinct()) + + if available_groups: + group_code = random.choice(available_groups) + else: + # DB에 물고기 데이터가 하나도 없을 경우 대비 (Fallback) + group_code = getattr(settings, "DEFAULT_FISH_GROUP", "ShrimpWich") + + # 2. 커밋 수에 맞는 가장 높은 단계(Maturity)의 물고기 조회 + # 예: 해당 그룹에서 내 커밋 수보다 요구량이 작거나 같은 것 중 가장 높은 단계 + target_species = FishSpecies.objects.filter( + group_code=group_code, + required_commits__lte=commit_count + ).order_by('-maturity').first() + + if not target_species: + # 조건에 맞는 게 없다면(커밋 0개 등), 해당 그룹의 1단계(Lv.1) 강제 할당 + target_species = FishSpecies.objects.filter(group_code=group_code, maturity=1).first() + + if not target_species: + logger.error(f"No FishSpecies found for group {group_code}") + return None + + # 3. 유저의 아쿠아리움 가져오기 (없으면 생성) + user_aquarium, _ = Aquarium.objects.get_or_create(user=user) + + # 4. ContributionFish 생성 또는 업데이트 + if not current_fish: + # 신규 생성 + current_fish = ContributionFish.objects.create( + contributor=contributor, + fish_species=target_species, + aquarium=user_aquarium, # [핵심] 생성 시 아쿠아리움에 바로 넣기 + is_visible_in_aquarium=True + ) + else: + # 기존 물고기 업데이트 + if current_fish.fish_species != target_species: + current_fish.fish_species = target_species + current_fish.save() + + # 혹시 아쿠아리움 연결이 끊겨있다면 다시 연결 (데이터 보정) + if not current_fish.aquarium: + current_fish.aquarium = user_aquarium + current_fish.is_visible_in_aquarium = True + current_fish.save() + + # 5. 도감(UnlockedFish) 업데이트 (Fishdex) + UnlockedFish.objects.get_or_create( + user=user, + fish_species=target_species + ) + + return current_fish \ No newline at end of file diff --git a/apps/aquatics/migrations/0001_initial.py b/apps/aquatics/migrations/0001_initial.py index 30ceca4..c96e61d 100644 --- a/apps/aquatics/migrations/0001_initial.py +++ b/apps/aquatics/migrations/0001_initial.py @@ -1,8 +1,7 @@ -# Generated by Django 5.2.6 on 2025-11-15 00:41 +# Generated by Django 4.2.27 on 2025-12-19 07:15 -import django.db.models.deletion -from django.conf import settings from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -11,8 +10,6 @@ class Migration(migrations.Migration): dependencies = [ ('items', '0001_initial'), - ('repositories', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -21,25 +18,23 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('svg_path', models.CharField(blank=True, help_text='The relative path to the generated SVG file.', max_length=512)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='aquarium', to=settings.AUTH_USER_MODEL)), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( name='ContributionFish', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_visible', models.BooleanField(default=True, help_text='Determines whether this fish is visible in the fishtank.')), - ('aquarium', models.ForeignKey(blank=True, help_text='The aquarium this fish has been added to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fishes', to='aquatics.aquarium')), - ('contributor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='contribution_fish', to='repositories.contributor')), - ('fish_species', models.ForeignKey(help_text='The species assigned to this contributor.', on_delete=django.db.models.deletion.PROTECT, to='items.fishspecies')), + ('is_visible_in_fishtank', models.BooleanField(default=True, help_text='Determines whether this fish is visible in the repository fishtank.')), + ('is_visible_in_aquarium', models.BooleanField(default=True, help_text='Determines whether this fish is visible in the personal aquarium.')), ], ), migrations.CreateModel( name='Fishtank', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('svg_path', models.CharField(blank=True, help_text='The relative path to the generated SVG file.', max_length=512)), - ('repository', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='fishtank', to='repositories.repository')), + ('svg_path', models.CharField(blank=True, help_text='유저의 설정이 반영되어 생성된 SVG 파일 경로.', max_length=512)), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( @@ -47,29 +42,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('unlocked_at', models.DateTimeField(auto_now_add=True, help_text='Timestamp when the user unlocked this background.')), - ('background', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='items.background')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_backgrounds', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'unique_together': {('user', 'background')}, - }, - ), - migrations.AddField( - model_name='aquarium', - name='background', - field=models.ForeignKey(blank=True, help_text='The background chosen by the user from their collection.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='aquatics.ownbackground'), - ), - migrations.CreateModel( - name='FishtankSetting', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('contributor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('fishtank', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='aquatics.fishtank')), - ('background', models.ForeignKey(blank=True, help_text='The background chosen by the contributor for this fishtank.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='aquatics.ownbackground')), ], - options={ - 'unique_together': {('fishtank', 'contributor')}, - }, ), migrations.CreateModel( name='UnlockedFish', @@ -77,10 +50,6 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('unlocked_at', models.DateTimeField(auto_now_add=True, help_text='Timestamp when the user unlocked this fish species.')), ('fish_species', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='items.fishspecies')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_fishes', to=settings.AUTH_USER_MODEL)), ], - options={ - 'unique_together': {('user', 'fish_species')}, - }, ), ] diff --git a/apps/aquatics/migrations/0002_initial.py b/apps/aquatics/migrations/0002_initial.py new file mode 100644 index 0000000..8f108f4 --- /dev/null +++ b/apps/aquatics/migrations/0002_initial.py @@ -0,0 +1,87 @@ +# Generated by Django 4.2.27 on 2025-12-19 07:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('repositories', '0001_initial'), + ('items', '0001_initial'), + ('aquatics', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='unlockedfish', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_fishes', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='ownbackground', + name='background', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='items.background'), + ), + migrations.AddField( + model_name='ownbackground', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_backgrounds', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='fishtank', + name='background', + field=models.ForeignKey(blank=True, help_text='이 수족관 뷰에 대해 유저가 설정한 배경입니다.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='aquatics.ownbackground'), + ), + migrations.AddField( + model_name='fishtank', + name='repository', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fishtanks', to='repositories.repository'), + ), + migrations.AddField( + model_name='fishtank', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fishtanks', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='contributionfish', + name='aquarium', + field=models.ForeignKey(blank=True, help_text='The aquarium this fish has been added to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fishes', to='aquatics.aquarium'), + ), + migrations.AddField( + model_name='contributionfish', + name='contributor', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='contribution_fish', to='repositories.contributor'), + ), + migrations.AddField( + model_name='contributionfish', + name='fish_species', + field=models.ForeignKey(help_text='The species assigned to this contributor.', on_delete=django.db.models.deletion.PROTECT, to='items.fishspecies'), + ), + migrations.AddField( + model_name='aquarium', + name='background', + field=models.ForeignKey(blank=True, help_text='The background chosen by the user from their collection.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='aquatics.ownbackground'), + ), + migrations.AddField( + model_name='aquarium', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='aquarium', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='unlockedfish', + unique_together={('user', 'fish_species')}, + ), + migrations.AlterUniqueTogether( + name='ownbackground', + unique_together={('user', 'background')}, + ), + migrations.AlterUniqueTogether( + name='fishtank', + unique_together={('repository', 'user')}, + ), + ] diff --git a/apps/aquatics/migrations/0002_remove_contributionfish_is_visible_and_more.py b/apps/aquatics/migrations/0002_remove_contributionfish_is_visible_and_more.py deleted file mode 100644 index 57929a2..0000000 --- a/apps/aquatics/migrations/0002_remove_contributionfish_is_visible_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.6 on 2025-11-29 04:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('aquatics', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='contributionfish', - name='is_visible', - ), - migrations.AddField( - model_name='contributionfish', - name='is_visible_in_aquarium', - field=models.BooleanField(default=True, help_text='Determines whether this fish is visible in the personal aquarium.'), - ), - migrations.AddField( - model_name='contributionfish', - name='is_visible_in_fishtank', - field=models.BooleanField(default=True, help_text='Determines whether this fish is visible in the repository fishtank.'), - ), - ] diff --git a/apps/aquatics/models.py b/apps/aquatics/models.py index e7a3850..83dd8fc 100644 --- a/apps/aquatics/models.py +++ b/apps/aquatics/models.py @@ -78,6 +78,7 @@ class Aquarium(models.Model): blank=True, help_text="The relative path to the generated SVG file." ) + updated_at = models.DateTimeField(auto_now=True) # 추가 def __str__(self): return f"{self.user.username}'s Aquarium" @@ -85,22 +86,39 @@ def __str__(self): class Fishtank(models.Model): """ - Represents a shared fishtank for a single repository. - It contains fish representing each contributor. + 특정 레포지토리에 대한 '개별 유저의 뷰'를 담당하는 수족관입니다. + 유저마다 같은 레포지토리를 서로 다른 배경으로 볼 수 있습니다. """ - repository = models.OneToOneField( + repository = models.ForeignKey( Repository, on_delete=models.CASCADE, - related_name='fishtank' + related_name='fishtanks' + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='fishtanks' + ) + background = models.ForeignKey( + OwnBackground, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="이 수족관 뷰에 대해 유저가 설정한 배경입니다." ) svg_path = models.CharField( max_length=512, blank=True, - help_text="The relative path to the generated SVG file." + help_text="유저의 설정이 반영되어 생성된 SVG 파일 경로." ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('repository', 'user') def __str__(self): - return f"Fishtank for {self.repository.name}" + return f"{self.user.username}'s view of {self.repository.name}" + class ContributionFish(models.Model): """ @@ -137,32 +155,4 @@ class ContributionFish(models.Model): def __str__(self): if self.aquarium: return f"Fish for {self.contributor.user.username} in {self.contributor.repository.name}'s Fishtank (in {self.aquarium})" - return f"Fish for {self.contributor.user.username} in {self.contributor.repository.name}'s Fishtank" - - -class FishtankSetting(models.Model): - """ - Stores a contributor's chosen background for a specific Fishtank. - """ - fishtank = models.ForeignKey( - Fishtank, - on_delete=models.CASCADE, - related_name='settings' - ) - contributor = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE - ) - background = models.ForeignKey( - OwnBackground, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="The background chosen by the contributor for this fishtank." - ) - - class Meta: - unique_together = ('fishtank', 'contributor') - - def __str__(self): - return f"Setting by {self.contributor.username} for {self.fishtank}" \ No newline at end of file + return f"Fish for {self.contributor.user.username} in {self.contributor.repository.name}'s Fishtank" \ No newline at end of file diff --git a/apps/aquatics/renderer/__init__.py b/apps/aquatics/renderer/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/aquatics/renderer/sprite.py b/apps/aquatics/renderer/sprite.py deleted file mode 100644 index 8995e65..0000000 --- a/apps/aquatics/renderer/sprite.py +++ /dev/null @@ -1,116 +0,0 @@ -# apps/aquatics/renderer/sprite.py -import random -from .utils import strip_outer_svg - -def apply_sprite_id(svg_template: str, fish_id: int) -> str: - """ - species.svg_template 안의 *{id} 플레이스홀더를 - 개별 물고기 id(예: 3, 7, 12)로 치환한다. - """ - if not svg_template: - return "" - return svg_template.replace("*{id}", str(fish_id)) - - -def render_fish_group(cf, width, height,mode): - species = cf.fish_species - fish_id = cf.id - raw_svg = species.svg_template - templated_svg=apply_sprite_id(raw_svg, fish_id) - inner = strip_outer_svg(templated_svg) - - - # === 레이블 내용 === - if mode == "aquarium": - label_text = cf.contributor.repository.name - else: # fishtank - label_text = cf.contributor.user.username - - # === 랜덤 요소 생성 === - start_x = random.uniform(width * 0.1, width * 0.9) - start_y = random.uniform(height * 0.2, height * 0.8) - amplitude_x = random.uniform(width * 0.25, width * 0.45) # x 왕복폭 - amplitude_y = random.uniform(height * 0.05, height * 0.12) # y 파동 - #speed = random.uniform(0.6, 1.6) # 느린 물고기 / 빠른 물고기 - #phase = random.uniform(0, 6.28) - duration = random.uniform(8, 18) # 전체 이동 주기 - - # === 이동 keyframes === - keyframes = f""" - @keyframes move-{fish_id} {{ - 0% {{ - transform: translate({start_x}px, {start_y}px); - }} - 25% {{ - transform: translate({start_x + amplitude_x}px, {start_y + amplitude_y}px); - }} - 50% {{ - transform: translate({start_x}px, {start_y - amplitude_y}px) scale(-1,1); - }} - 75% {{ - transform: translate({start_x - amplitude_x}px, {start_y + amplitude_y}px) scale(-1,1); - }} - 100% {{ - transform: translate({start_x}px, {start_y}px); - }} - }} - """ - - # === flip reverse keyframes === - reverse_keyframes = f""" - @keyframes keep-label-upright-{fish_id} {{ - 0%,25% {{ - transform: scale(1,1); - }} - 50%,75% {{ - transform: scale(-1,1); /* 물고기가 뒤집힐 때 라벨도 같이 뒤집혀서 정방향 유지 */ - }} - 100% {{ - transform: scale(1,1); - }} - }} - """ - - return f""" - - - - - - {inner} - - - - - - {label_text} - - - - - """ \ No newline at end of file diff --git a/apps/aquatics/renderer/tank.py b/apps/aquatics/renderer/tank.py deleted file mode 100644 index e1b684e..0000000 --- a/apps/aquatics/renderer/tank.py +++ /dev/null @@ -1,66 +0,0 @@ -# apps/aquatics/renderer/tank.py -from apps.aquatics.models import Aquarium,ContributionFish , Fishtank -from .sprite import render_fish_group - -def render_aquarium_svg(user,width=512, height=512): - aquarium = Aquarium.objects.get(user=user) - - if aquarium.background and aquarium.background.background.background_image: - bg_url = aquarium.background.background.background_image.url - else: - bg_url = "" - #width, height = extract_svg_size(bg_svg) - #bg_inner = strip_outer_svg(bg_svg) - width = 512 - height = 512 - fishes = ContributionFish.objects.filter( - aquarium=aquarium, - is_visible_in_aquarium=True - ).select_related("fish_species", "contributor__repository") - - fish_groups = [ - render_fish_group(cf, width, height, mode="aquarium") - for cf in fishes - ] - - return f""" - - - - - {''.join(fish_groups)} - - - """ - - -def render_fishtank_svg(repository): - fishtank = Fishtank.objects.get(repository=repository) - - - setting = fishtank.settings.select_related("background__background").first() - if setting and setting.background and setting.background.background.background_image: - bg_url = setting.background.background.background_image.url - else: - bg_url = "" - - #width, height = extract_svg_size(bg_svg) - #bg_inner = strip_outer_svg(bg_svg) - width = 512 - height = 512 - fishes = ContributionFish.objects.filter( - contributor__repository=repository, - is_visible_in_fishtank=True - ).select_related("fish_species", "contributor__user") - - fish_groups = [ - render_fish_group(cf, width, height, mode="fishtank") - for cf in fishes - ] - - return f""" - - - {''.join(fish_groups)} - - """ \ No newline at end of file diff --git a/apps/aquatics/renderer/utils.py b/apps/aquatics/renderer/utils.py deleted file mode 100644 index 0af216a..0000000 --- a/apps/aquatics/renderer/utils.py +++ /dev/null @@ -1,53 +0,0 @@ -# apps/aquatics/renderer/utils.py -import re - -def extract_svg_size(svg_text: str): - """ - Extract width and height from tag. - Supports: - - - Returns (width, height) as numbers. - Defaults to (512, 512) if not found. - """ - if not svg_text: - return (512, 512) - - # width="512", height="512" - width_match = re.search(r'width="([\d\.]+)"', svg_text) - height_match = re.search(r'height="([\d\.]+)"', svg_text) - - if width_match and height_match: - return (float(width_match.group(1)), float(height_match.group(1))) - - # viewBox="0 0 800 600" - viewbox_match = re.search(r'viewBox="[^"]*?(\d+\.?\d*)\s+(\d+\.?\d*)"', svg_text) - if viewbox_match: - return (float(viewbox_match.group(1)), float(viewbox_match.group(2))) - - # fallback - return (512, 512) - - -def strip_outer_svg(svg_text: str) -> str: - """ - species/background 템플릿이 풀 SVG로 들어있을 때 - 가장 바깥 래퍼만 제거하고 안쪽 노드만 반환. - """ - if not svg_text: - return "" - - # Find first tag after - start = svg_text.find(">") + 1 - end = svg_text.rfind("") - if start <= 0 or end == -1: - return svg_text.strip() - return svg_text[start:end].strip() - -def safe_attr(value): - """ - Convert None or empty to safe attribute values. - """ - if value is None: - return "" - return str(value) \ No newline at end of file diff --git a/apps/aquatics/renderers.py b/apps/aquatics/renderers.py new file mode 100644 index 0000000..3bfd4a1 --- /dev/null +++ b/apps/aquatics/renderers.py @@ -0,0 +1,411 @@ +# apps/aquatics/renderers.py +import random +import re +import logging +from django.conf import settings +from django.db.models import Q +from apps.aquatics.models import Aquarium, ContributionFish, Fishtank + +logger = logging.getLogger(__name__) + +# --- Utilities --- + +FONT_FAMILY = '"Bungee", "Space Mono", monospace' + +def _strip_outer_svg(svg_text: str) -> str: + """ + 가장 바깥쪽 태그를 제거하고 내부 요소만 반환합니다. + """ + if not svg_text: + return "" + start = svg_text.find(">") + 1 + end = svg_text.rfind("") + if start <= 0 or end == -1: + return svg_text.strip() + return svg_text[start:end].strip() + + +def _apply_sprite_id(svg_template: str, fish_id: int) -> str: + """ + SVG 내부의 ID 중복을 방지하기 위해 *{id} 플레이스홀더를 치환합니다. + """ + if not svg_template: + return "" + return svg_template.replace("*{id}", str(fish_id)) + + +def _get_absolute_url(relative_path: str) -> str: + """ + 상대 경로(예: /media/bg.png)를 입력받아 + settings.SITE_DOMAIN을 결합한 절대 경로를 반환합니다. + Github Readme 등 외부에서 이미지가 깨지지 않도록 하기 위함입니다. + """ + if not relative_path: + return "" + if relative_path.startswith('http'): + return relative_path + + # settings.py에 SITE_DOMAIN이 정의되어 있어야 함 (기본값 localhost) + domain = getattr(settings, 'SITE_DOMAIN', 'http://localhost:8000') + + # 경로가 /로 시작하지 않으면 붙여줌 + if not relative_path.startswith('/'): + relative_path = f'/{relative_path}' + + return f"{domain}{relative_path}" + +def _escape_text(s: str) -> str: + if s is None: + return "" + return ( + str(s) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + +def _bg_url_from_ownbackground(own_bg) -> str: + if not own_bg: + return "" + try: + bg = getattr(own_bg, "background", None) + if bg and getattr(bg, "background_image", None): + return _get_absolute_url(bg.background_image.url) + except Exception: + pass + return "" + + +def _parse_viewbox(svg_text: str): + m = re.search(r'viewBox\s*=\s*"([^"]+)"', svg_text, re.I) + if not m: + return (0.0, 0.0, 50.0, 50.0) + parts = [float(x) for x in m.group(1).split()] + if len(parts) != 4: + return (0.0, 0.0, 50.0, 50.0) + return tuple(parts) # minx, miny, w, h + +def _find_anchor_xy(svg_text: str, anchor_id: str): + """ + circle: cx/cy + rect: x/y (+ width/height center) + fallback: None + """ + if not svg_text or not anchor_id: + return None + + # id="...anchor_id..." + # 1) circle + m = re.search( + rf']*\bid\s*=\s*"{re.escape(anchor_id)}"[^>]*>', + svg_text, re.I + ) + if m: + tag = m.group(0) + cx = re.search(r'\bcx\s*=\s*"([^"]+)"', tag, re.I) + cy = re.search(r'\bcy\s*=\s*"([^"]+)"', tag, re.I) + if cx and cy: + return (float(cx.group(1)), float(cy.group(1))) + + # 2) rect + m = re.search( + rf']*\bid\s*=\s*"{re.escape(anchor_id)}"[^>]*>', + svg_text, re.I + ) + if m: + tag = m.group(0) + x = re.search(r'\bx\s*=\s*"([^"]+)"', tag, re.I) + y = re.search(r'\by\s*=\s*"([^"]+)"', tag, re.I) + w = re.search(r'\bwidth\s*=\s*"([^"]+)"', tag, re.I) + h = re.search(r'\bheight\s*=\s*"([^"]+)"', tag, re.I) + if x and y: + xx = float(x.group(1)) + yy = float(y.group(1)) + if w and h: + return (xx + float(w.group(1))/2.0, yy + float(h.group(1))/2.0) + return (xx, yy) + + return None + +# --- Sprite Renderer --- +def _clamp(v, a, b): + return max(a, min(b, v)) + +def render_fish_group(cf, tank_w, tank_h, mode, persona_width_percent=4, padding=8): + species = cf.fish_species + fish_id = cf.id + + raw_svg = getattr(species, "svg_template", "") or "" + templated_svg = _apply_sprite_id(raw_svg, fish_id) + inner = _strip_outer_svg(templated_svg) + + # ---- label text ---- + if mode == "aquarium": + top_label = _escape_text(getattr(cf.contributor.repository, "name", "")) + bottom_label = _escape_text(f"{getattr(cf.contributor, 'commit_count', 0)} commits") + else: + top_label = _escape_text(getattr(cf.contributor.user, "username", "")) + bottom_label = _escape_text(f"{getattr(cf.contributor, 'commit_count', 0)} commits") + + # ---- viewBox ---- + vb_minx, vb_miny, vb_w, vb_h = _parse_viewbox(templated_svg) + + # 프론트: baseW = tankW * (percent/100), spriteW = baseW*2 + baseW = tank_w * (persona_width_percent / 100.0) + spriteW = baseW * 6.0 + scale = spriteW / max(1e-6, vb_w) + spriteH = vb_h * scale + + # 탱크 안에서만 움직이도록 (패딩 + 스프라이트 크기 고려) + minX = padding + maxX = max(padding, tank_w - padding - spriteW) + minY = padding + maxY = max(padding, tank_h - padding - spriteH*(0.7)) + + # ---- movement points (밖으로 안 나가게!) ---- + x0 = random.uniform(minX, maxX) + y0 = random.uniform(minY, maxY) + x1 = random.uniform(minX, maxX) + y1 = random.uniform(minY, maxY) + + # 살짝만 위아래 흔들 (프론트처럼 과하지 않게) + wiggle = min(spriteH * 0.10, 10.0) + y0a = _clamp(y0 + random.uniform(-wiggle, wiggle), minY, maxY) + y1a = _clamp(y1 + random.uniform(-wiggle, wiggle), minY, maxY) + + duration = random.uniform(10, 20) + + # ---- anchors in template coord -> pixel coord (프론트 로직 이식) ---- + top_anchor_id = f"{fish_id}-anchor-label-top" + bottom_anchor_id = f"{fish_id}-anchor-label-bottom" + center_anchor_id = f"{fish_id}-anchor-center" + + top_xy = ( + _find_anchor_xy(templated_svg, top_anchor_id) + or _find_anchor_xy(templated_svg, center_anchor_id) + or (vb_minx + vb_w / 2.0, vb_miny) + ) + bot_xy = ( + _find_anchor_xy(templated_svg, bottom_anchor_id) + or (vb_minx + vb_w / 2.0, vb_miny + vb_h) + ) + + # (ax-minX)*scale 로 픽셀화 + top_px = (top_xy[0] - vb_minx) * scale + top_py = (top_xy[1] - vb_miny) * scale + bot_px = (bot_xy[0] - vb_minx) * scale + bot_py = (bot_xy[1] - vb_miny) * scale + + # ---- font size: 프론트 기반 (top 조금 더 큼/굵게) ---- + baseSize = max(10.0, baseW * 0.22) + topFont = baseSize * 1.1 + botFont = baseSize * 0.85 + + # ---- keyframes: p0 -> p1 -> p0 (linear 대신 ease-in-out는 유지 가능) ---- + move_kf = f""" + @keyframes move-{fish_id} {{ + 0% {{ transform: translate({x0}px, {y0a}px); }} + 50% {{ transform: translate({x1}px, {y1a}px); }} + 100% {{ transform: translate({x0}px, {y0a}px); }} + }} + """ + + # flip은 50%에서 딱 반전만 (빙글빙글 X) + flip_kf = f""" + @keyframes flip-{fish_id} {{ + 0%, 49.999% {{ transform: scale(1); }} + 50%, 100% {{ transform: scaleX(-1); }} + }} + """ + + return f""" + + + + + + + + + {inner} + + + + + {top_label} + + {bottom_label} + + + """ + + +# --- Main Renderers --- + +def render_aquarium_svg(user, width=700, height=400): + """ + 유저의 개인 아쿠아리움 SVG 렌더링 + - user 기준으로 Aquarium을 추측하지 않음 + - ContributionFish에 실제로 연결된 aquarium을 기준으로 렌더 + """ + fishes = ( + ContributionFish.objects + .filter( + contributor__user=user, + is_visible_in_aquarium=True, + ) + .select_related( + "aquarium", + "fish_species", + "contributor__repository", + "contributor__user", + ) + ) + + if not fishes.exists(): + logger.warning(f"[render_aquarium_svg] user={user.id} has no visible fish") + # 그래도 SVG는 반환 + return f""" + + + No fish in aquarium + + """ + + aquarium, _ = Aquarium.objects.get_or_create(user=user) + + logger.warning( + f"[render_aquarium_svg] user={user.id} aquarium_id={aquarium.id} fish_count={fishes.count()}" + ) + + bg_url = "" + if ( + aquarium.background + and aquarium.background.background + and aquarium.background.background.background_image + ): + bg_url = _get_absolute_url( + aquarium.background.background.background_image.url + ) + + fish_groups = [ + render_fish_group( + cf, + tank_w=width, + tank_h=height, + mode="aquarium", + persona_width_percent=4, # 프론트 기본값 맞춤 + padding=8, # 프론트 기본값 맞춤 + ) + for cf in fishes + ] + + return f""" + + + + + + + + + + + {f'' if bg_url else ''} + + {''.join(fish_groups)} + + + + """ + +def render_fishtank_svg(repository, user, width=700, height=400): + """ + 레포지토리 공용 피시탱크를 특정 유저의 배경 설정에 맞춰 렌더링합니다. + """ + try: + # 해당 유저의 피시탱크 설정 조회 + fishtank = Fishtank.objects.select_related('background__background').get( + repository=repository, + user=user + ) + bg_url = "" + if fishtank.background and fishtank.background.background.background_image: + raw_url = fishtank.background.background.background_image.url + bg_url = _get_absolute_url(raw_url) + + except Fishtank.DoesNotExist: + bg_url = "" + + # 물고기들은 해당 레포지토리의 모든 기여자들 것을 가져옴 + fishes = ContributionFish.objects.filter( + contributor__repository=repository, + is_visible_in_fishtank=True + ).select_related("fish_species", "contributor__user") + + fish_groups = [ + render_fish_group( + cf, + tank_w=width, + tank_h=height, + mode="fishtank", + persona_width_percent=4, # 프론트 기본값 맞춤 + padding=8, # 프론트 기본값 맞춤 + ) + for cf in fishes + ] + + return f""" + + {f'' if bg_url else ''} + + {''.join(fish_groups)} + + """ + + + diff --git a/apps/aquatics/serializers.py b/apps/aquatics/serializers.py new file mode 100644 index 0000000..000c4e0 --- /dev/null +++ b/apps/aquatics/serializers.py @@ -0,0 +1,176 @@ +# apps/aquatics/serializers.py +from rest_framework import serializers +from django.conf import settings +from .models import Aquarium, Fishtank, ContributionFish, UnlockedFish, OwnBackground +from drf_yasg.utils import swagger_serializer_method + +class FishSerializer(serializers.ModelSerializer): + """아쿠아리움/피시탱크 내부 물고기 상세 정보""" + name = serializers.CharField(source='fish_species.name', read_only=True, help_text="물고기 종 이름") + group_code = serializers.CharField(source='fish_species.group_code', read_only=True, help_text="진화 그룹 코드") + maturity = serializers.IntegerField(source='fish_species.maturity', read_only=True, help_text="성장 단계 (1~6)") + repository_name = serializers.CharField(source='contributor.repository.full_name', read_only=True, help_text="출처 레포지토리 풀네임") + commit_count = serializers.IntegerField(source='contributor.commit_count', read_only=True, help_text="해당 레포지토리에 기여한 커밋 수") + + # [추가] 해당 물고기 주인의 GitHub Username + github_username = serializers.CharField(source='contributor.user.github_username', read_only=True, help_text="기여자의 GitHub Username") + + unlocked_at = serializers.SerializerMethodField(help_text="해당 물고기 종을 해금한 시각 (Fishdex용)") + + class Meta: + model = ContributionFish + fields = [ + 'id', + 'name', + 'group_code', + 'maturity', + 'repository_name', + 'commit_count', + 'github_username', # <--- 필드 추가 + 'unlocked_at', + 'is_visible_in_aquarium', + 'is_visible_in_fishtank' + ] + + def get_unlocked_at(self, obj): + """ + N+1 방지 로직: + Context에 미리 로딩된 unlocked_map이 있으면 그것을 사용하고, + 없으면 DB를 조회합니다. + """ + # 1. Context에서 캐시 확인 (최적화) + unlocked_map = self.context.get('unlocked_map') + if unlocked_map is not None: + return unlocked_map.get(obj.fish_species_id) + + # 2. 캐시가 없으면 DB 조회 (기본 동작) + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return None + + unlocked_record = UnlockedFish.objects.filter( + user=request.user, + fish_species_id=obj.fish_species_id + ).first() + return unlocked_record.unlocked_at if unlocked_record else None + + +class AquariumDetailSerializer(serializers.ModelSerializer): + """개인 아쿠아리움 상세 정보 (SVG URL 포함)""" + svg_url = serializers.SerializerMethodField(help_text="생성된 아쿠아리움 SVG 파일의 절대 경로") + background_name = serializers.CharField(source='background.background.name', read_only=True, default="기본 배경") + fish_list = FishSerializer(source='fishes', many=True, read_only=True, help_text="아쿠아리움에 배치된 물고기 목록") + + class Meta: + model = Aquarium + fields = ['id', 'svg_url', 'background_name', 'fish_list'] + + def get_svg_url(self, obj): + if not obj.svg_path: + return None + request = self.context.get('request') + timestamp = int(obj.updated_at.timestamp()) + full_path = f"{settings.MEDIA_URL}{obj.svg_path}?t={timestamp}" + return request.build_absolute_uri(full_path) if request else full_path + + +class FishtankDetailSerializer(serializers.ModelSerializer): + """레포지토리 수족관 상세 정보 (SVG URL 포함)""" + repository_full_name = serializers.CharField(source='repository.full_name', read_only=True) + svg_url = serializers.SerializerMethodField(help_text="생성된 피시탱크 SVG 파일의 절대 경로") + background_name = serializers.SerializerMethodField(help_text="해당 유저가 설정한 수족관 배경 이름") + fish_list = serializers.SerializerMethodField(help_text="수족관에 노출 설정된 모든 기여자의 물고기 목록") + + class Meta: + model = Fishtank + fields = ['id', 'repository_full_name', 'svg_url', 'background_name', 'fish_list'] + + def get_svg_url(self, obj): + if not obj.svg_path: + return None + request = self.context.get('request') + timestamp = int(obj.updated_at.timestamp()) + full_path = f"{settings.MEDIA_URL}{obj.svg_path}?t={timestamp}" + return request.build_absolute_uri(full_path) if request else full_path + + def get_background_name(self, obj): + # Fishtank 모델이 직접 OwnBackground를 가짐 + if obj.background and obj.background.background: + return obj.background.background.name + return "기본 배경" + + @swagger_serializer_method(serializer_or_field=FishSerializer(many=True)) + def get_fish_list(self, obj): + # 1. 물고기 목록 조회 (Related Join 최적화) + # contributor__user를 select_related에 포함하여 github_username 조회 시 쿼리 절약 + fishes = ContributionFish.objects.filter( + contributor__repository=obj.repository, + is_visible_in_fishtank=True + ).select_related('fish_species', 'contributor__repository', 'contributor__user') + + # 2. [N+1 최적화] 현재 유저의 도감 정보를 한 번에 조회하여 Map 생성 + request = self.context.get('request') + unlocked_map = {} + if request and request.user.is_authenticated: + # {species_id: unlocked_at} 딕셔너리 생성 + unlocked_records = UnlockedFish.objects.filter(user=request.user).values('fish_species_id', 'unlocked_at') + unlocked_map = {r['fish_species_id']: r['unlocked_at'] for r in unlocked_records} + + # 3. Serializer Context에 Map 전달 + context = self.context.copy() + context['unlocked_map'] = unlocked_map + + return FishSerializer(fishes, many=True, context=context).data + + +class BackgroundChangeSerializer(serializers.Serializer): + background_id = serializers.IntegerField(help_text="적용할 배경(Background 모델)의 고유 ID") + + +class FishVisibilityItemSerializer(serializers.Serializer): + id = serializers.IntegerField(help_text="기여 물고기(ContributionFish)의 고유 ID") + visible = serializers.BooleanField(help_text="표시 여부 (true: 표시, false: 숨김)") + + +class FishVisibilityBulkUpdateSerializer(serializers.Serializer): + fish_settings = FishVisibilityItemSerializer(many=True, help_text="물고기별 노출 설정 리스트") + + +class OwnBackgroundListSerializer(serializers.ModelSerializer): + """유저가 보유한 배경 목록 (인벤토리용)""" + background_id = serializers.IntegerField(source='background.id', read_only=True) + name = serializers.CharField(source='background.name', read_only=True) + image_url = serializers.SerializerMethodField() + + class Meta: + model = OwnBackground + fields = ['background_id', 'name', 'image_url', 'unlocked_at'] + + def get_image_url(self, obj): + if obj.background.background_image: + request = self.context.get('request') + return request.build_absolute_uri(obj.background.background_image.url) if request else obj.background.background_image.url + return None + + +class UserFishListSerializer(serializers.ModelSerializer): + """유저가 획득한 모든 물고기 목록 (인벤토리용)""" + species_name = serializers.CharField(source='fish_species.name', read_only=True) + repository_full_name = serializers.CharField(source='contributor.repository.full_name', read_only=True) + group_code = serializers.CharField(source='fish_species.group_code', read_only=True) + maturity = serializers.IntegerField(source='fish_species.maturity', read_only=True) + github_username = serializers.CharField(source='contributor.user.github_username', read_only=True) # 내 목록에서도 본인 ID 표시 + + class Meta: + model = ContributionFish + fields = [ + 'id', + 'species_name', + 'group_code', + 'maturity', + 'repository_full_name', + 'github_username', + 'is_visible_in_fishtank', + 'is_visible_in_aquarium', + 'aquarium' + ] \ No newline at end of file diff --git a/apps/aquatics/serializers_aquarium.py b/apps/aquatics/serializers_aquarium.py deleted file mode 100644 index 6589265..0000000 --- a/apps/aquatics/serializers_aquarium.py +++ /dev/null @@ -1,83 +0,0 @@ -#aqutatics/serializers_aquarium.py -from rest_framework import serializers -from apps.aquatics.models import Aquarium, ContributionFish, OwnBackground -from apps.items.models import Background - - -class AquariumFishSerializer(serializers.ModelSerializer): - species = serializers.SerializerMethodField() - repository = serializers.SerializerMethodField() - my_commit_count = serializers.SerializerMethodField() - unlocked_at = serializers.SerializerMethodField() - - class Meta: - model = ContributionFish - fields = [ - "id", - "species", - "repository", - "my_commit_count", - "unlocked_at", - "is_visible_in_aquarium", - ] - - def get_species(self, obj): - s = obj.fish_species - return { - "id": s.id, - "name": s.name, - "maturity": s.maturity, - "required_commits": s.required_commits, - "svg_template": s.svg_template, - "group_code": s.group_code, - } - - def get_repository(self, obj): - repo = obj.contributor.repository - return { - "id": repo.id, - "name": repo.name, - } - - def get_my_commit_count(self, obj): - return obj.contributor.commit_count - - def get_unlocked_at(self, obj): - # ContributionFish는 unlocked_at을 직접 갖지 않으므로 UnlockedFish에서 조회 - unlocked = obj.contributor.user.owned_fishes.filter( - fish_species=obj.fish_species - ).first() - return unlocked.unlocked_at if unlocked else None - - -class AquariumDetailSerializer(serializers.ModelSerializer): - background = serializers.SerializerMethodField() - fishes = AquariumFishSerializer(many=True) - - class Meta: - model = Aquarium - fields = ["background", "svg_path", "fishes"] - - def get_background(self, obj): - if obj.background: - return { - "id": obj.background.id, - "name": obj.background.background.name, - "svg_template": obj.background.background.svg_template, - } - return None - - -class BackgroundSerializerForAquarium(serializers.ModelSerializer): - """Background 모델을 직렬화하는 nested serializer""" - class Meta: - model = Background - fields = ["id", "name", "code"] # Background 모델에는 svg_template이 없음 - - -class AquariumBackgroundSerializer(serializers.ModelSerializer): - background = BackgroundSerializerForAquarium(read_only=True) - - class Meta: - model = OwnBackground - fields = ["id", "background", "unlocked_at"] diff --git a/apps/aquatics/serializers_fishtank.py b/apps/aquatics/serializers_fishtank.py deleted file mode 100644 index baad4aa..0000000 --- a/apps/aquatics/serializers_fishtank.py +++ /dev/null @@ -1,56 +0,0 @@ -# apps/aquatics/serializers_fishtank.py -from rest_framework import serializers -from apps.aquatics.models import Fishtank, ContributionFish -from apps.repositories.models import Contributor -from apps.items.models import Background - - -class FishSpeciesSerializer(serializers.Serializer): - id = serializers.IntegerField() - name = serializers.CharField() - maturity = serializers.IntegerField() - required_commits = serializers.IntegerField() - svg_template = serializers.CharField() - - -class ContributionFishSerializer(serializers.ModelSerializer): - species = serializers.SerializerMethodField() - - class Meta: - model = ContributionFish - fields = ["id", "is_visible_in_fishtank", "species"] - - def get_species(self, obj): - s = obj.fish_species - return { - "id": s.id, - "name": s.name, - "maturity": s.maturity, - "required_commits": s.required_commits, - "svg_template": s.svg_template, - "group_code": s.group_code, - } - - -class ContributorSerializer(serializers.ModelSerializer): - user = serializers.CharField(source="user.username") - fish = ContributionFishSerializer(source="contribution_fish", read_only=True) - - class Meta: - model = Contributor - fields = ["id", "user", "commit_count", "fish"] - - -class FishtankDetailSerializer(serializers.ModelSerializer): - repository = serializers.CharField(source="repository.name") - contributors = ContributorSerializer(source="repository.contributors", many=True) - - class Meta: - model = Fishtank - fields = ["id", "repository", "svg_path", "contributors"] - - -class FishtankBackgroundSerializer(serializers.ModelSerializer): - class Meta: - model = Background - fields = ["id", "name", "code", "background_image"] diff --git a/apps/aquatics/tasks.py b/apps/aquatics/tasks.py new file mode 100644 index 0000000..e02d644 --- /dev/null +++ b/apps/aquatics/tasks.py @@ -0,0 +1,105 @@ +# apps/aquatics/tasks.py +import logging +import os +from django.conf import settings +from django.contrib.auth import get_user_model +from .models import Aquarium, Fishtank +from .renderers import render_aquarium_svg, render_fishtank_svg +from apps.repositories.models import Repository + +# 로깅 설정 +logger = logging.getLogger(__name__) +User = get_user_model() + +def generate_aquarium_svg_task(user_id): + """ + 유저의 개인 아쿠아리움을 렌더링하여 저장합니다. + """ + try: + user = User.objects.get(id=user_id) + aquarium, _ = Aquarium.objects.get_or_create(user=user) + + # 1. SVG 텍스트 생성 + svg_content = render_aquarium_svg(user) + + if not svg_content: + logger.warning(f"Empty SVG content generated for Aquarium (User: {user.username})") + return + + # 2. 파일 저장 경로 설정 + file_name = f"aquariums/aquarium_{user.id}.svg" + path = os.path.join(settings.MEDIA_ROOT, file_name) + + # 3. 디렉토리 생성 및 파일 쓰기 + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "w", encoding="utf-8") as f: + f.write(svg_content) + + # 4. DB 업데이트 + aquarium.svg_path = file_name + aquarium.save(update_fields=['svg_path', 'updated_at']) + + logger.info(f"Successfully generated Aquarium SVG for user {user.username}") + + except User.DoesNotExist: + logger.error(f"User not found for generate_aquarium_svg_task: {user_id}") + except Exception as e: + logger.error(f"Error generating Aquarium SVG for user {user_id}: {e}", exc_info=True) + + +def generate_fishtank_svg_task(repo_id, user_id=None): + """ + 특정 레포지토리의 피시탱크 SVG를 생성합니다. + + - repo_id, user_id 모두 있음: 해당 유저의 피시탱크 뷰만 갱신 + - user_id가 None임: 해당 레포지토리를 구독 중인 '모든' 유저의 피시탱크 뷰 갱신 + """ + try: + # user_id가 없으면(Webhook 등에서 전체 갱신 요청 시) + if user_id is None: + fishtanks = Fishtank.objects.filter(repository_id=repo_id) + for ft in fishtanks: + _generate_single_fishtank(repo_id, ft.user_id) + else: + # 특정 유저만 갱신 + _generate_single_fishtank(repo_id, user_id) + + except Exception as e: + logger.error(f"Error in generate_fishtank_svg_task dispatch (Repo: {repo_id}): {e}", exc_info=True) + + +def _generate_single_fishtank(repo_id, user_id): + """ + 실제 피시탱크 SVG 생성 및 저장 로직 (내부 함수) + """ + try: + repo = Repository.objects.get(id=repo_id) + user = User.objects.get(id=user_id) + + # Fishtank 레코드가 없으면 생성, 있으면 가져옴 + fishtank, _ = Fishtank.objects.get_or_create(repository=repo, user=user) + + # 유저 정보를 넘겨서 렌더링 (해당 유저의 배경 설정 등 반영) + svg_content = render_fishtank_svg(repo, user) + + if not svg_content: + return + + file_name = f"fishtanks/repo_{repo.id}_user_{user.id}.svg" + path = os.path.join(settings.MEDIA_ROOT, file_name) + + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "w", encoding="utf-8") as f: + f.write(svg_content) + + fishtank.svg_path = file_name + fishtank.save(update_fields=['svg_path', 'updated_at']) + + logger.info(f"Generated Fishtank SVG for Repo {repo.full_name} / User {user.username}") + + except (Repository.DoesNotExist, User.DoesNotExist): + logger.error(f"Repo or User missing for Fishtank generation (Repo: {repo_id}, User: {user_id})") + except Exception as e: + logger.error(f"Error generating Fishtank SVG (Repo: {repo_id}, User: {user_id}): {e}", exc_info=True) \ No newline at end of file diff --git a/apps/aquatics/urls.py b/apps/aquatics/urls.py index 2570afe..c0c4f25 100644 --- a/apps/aquatics/urls.py +++ b/apps/aquatics/urls.py @@ -1,36 +1,53 @@ from django.urls import path -from .views import AquariumSVGView -from .views_fishtank import ( +from .views import ( + AquariumDetailView, + AquariumBackgroundUpdateView, + AquariumFishVisibilityUpdateView, FishtankDetailView, - FishtankSVGView, - FishtankBackgroundListView, - ApplyFishtankBackgroundView, - #FishtankExportView, - FishtankSelectableFishView, - FishtankExportSelectionView, - FishtankSpriteListView, -) -from .views_aquarium import * + FishtankBackgroundUpdateView, + FishtankFishVisibilityUpdateView, + UserContributionFishListView, + UserOwnBackgroundListView, + + #-- render 테스트--- + AquariumSvgPreviewView, + FishtankSvgPreviewView, + AquariumEmbedCodeView, + FishtankEmbedCodeView, +) +from .views_render import ( + PublicAquariumSvgRenderView, + PublicFishtankSvgRenderView, +) urlpatterns = [ - path("fishtank//", FishtankDetailView.as_view()), - path("fishtank//svg/", FishtankSVGView.as_view()), - path("fishtank/backgrounds/", FishtankBackgroundListView.as_view()), - path("fishtank//apply-background/", ApplyFishtankBackgroundView.as_view()), - #path("fishtank//export/", FishtankExportView.as_view()), - path("fishtank//apply-fish/", FishtankSelectableFishView.as_view()), - path("fishtank//export-selection/", FishtankExportSelectionView.as_view()), - path("fishtank//sprites/", FishtankSpriteListView.as_view(), name="fishtank-sprites"), + # --- 개인 아쿠아리움 관리 --- + path('aquarium/', AquariumDetailView.as_view(), name='aquarium-detail'), + path('aquarium/background/', AquariumBackgroundUpdateView.as_view(), name='aquarium-bg-update'), + path('aquarium/fishes/visibility/', AquariumFishVisibilityUpdateView.as_view(), name='aquarium-fish-visibility'), + + # --- 레포지토리 공용 수족관(피시탱크) 관리 --- + path('fishtank//', FishtankDetailView.as_view(), name='fishtank-detail'), + path('fishtank//background/', FishtankBackgroundUpdateView.as_view(), name='fishtank-bg-update'), + path('fishtank//fishes/visibility/', FishtankFishVisibilityUpdateView.as_view(), name='fishtank-fish-visibility'), + + # --- 유저 인벤토리(보유 자산) 조회 --- + path('my-fishes/', UserContributionFishListView.as_view(), name='user-fish-list'), + path('my-backgrounds/', UserOwnBackgroundListView.as_view(), name='user-backgrounds-list'), - path("aquarium/", AquariumDetailView.as_view()), - path("my-fishes/", MyUnlockedFishListView.as_view()), - #path("aquarium/add-fish/", AquariumAddFishView.as_view()), - #path("aquarium/remove-fish//", AquariumRemoveFishView.as_view()), - path("aquarium/backgrounds/", AquariumBackgroundListView.as_view()), - path("aquarium/apply-background/", AquariumApplyBackgroundView.as_view()), - #path("aquarium/export/", AquariumExportView.as_view()), - path("aquarium/svg/", AquariumSVGView.as_view()), - path("aquarium/sprites/", AquariumSpriteListView.as_view(), name="aquarium-sprites"), - path("aquarium/apply-fish/", AquariumSelectableFishView.as_view()), - path("aquarium/export-selection/", AquariumExportSelectionView.as_view()), -] + # ---render--- + path("aquarium/svg/preview/", AquariumSvgPreviewView.as_view()), + path("fishtank//svg/preview/", FishtankSvgPreviewView.as_view()), + #path("fishtank//svg/", FishtankSvgPathView.as_view()), + #path("aquarium/svg/", AquariumSvgPathView.as_view()), + path( + "render/aquarium//", + PublicAquariumSvgRenderView.as_view(), + ), + path( + "render/fishtank///", + PublicFishtankSvgRenderView.as_view(), + ), + path("embed/aquarium/", AquariumEmbedCodeView.as_view()), + path("embed/fishtank//", FishtankEmbedCodeView.as_view()), +] \ No newline at end of file diff --git a/apps/aquatics/views.py b/apps/aquatics/views.py index d9e23e2..8dec8f0 100644 --- a/apps/aquatics/views.py +++ b/apps/aquatics/views.py @@ -1,11 +1,372 @@ +# apps/aquatics/views.py +import os +from django.conf import settings +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django_q.tasks import async_task +from rest_framework import generics, status from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated -from .renderer.tank import render_aquarium_svg +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from django.http import HttpResponse #렌더 +from .renderers import render_aquarium_svg , render_fishtank_svg #렌더 -class AquariumSVGView(APIView): +from .models import Aquarium, Fishtank, OwnBackground, ContributionFish +from apps.repositories.models import Repository +from .serializers import ( + AquariumDetailSerializer, + FishtankDetailSerializer, + BackgroundChangeSerializer, + UserFishListSerializer, + OwnBackgroundListSerializer, + FishVisibilityBulkUpdateSerializer +) +from apps.aquatics.renderers import render_aquarium_svg, render_fishtank_svg +from apps.aquatics.tasks import generate_aquarium_svg_task,generate_fishtank_svg_task +import logging +logger = logging.getLogger(__name__) +# --- 개인 아쿠아리움 관련 --- + +class AquariumDetailView(generics.RetrieveAPIView): + permission_classes = [IsAuthenticated] + serializer_class = AquariumDetailSerializer + + def get_object(self): + user = self.request.user + aquarium, _ = Aquarium.objects.get_or_create(user=user) + if not aquarium.svg_path: + try: + svg_content = render_aquarium_svg(user) + if svg_content: + file_name = f"aquariums/aquarium_{user.id}.svg" + path = os.path.join(settings.MEDIA_ROOT, file_name) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(svg_content) + aquarium.svg_path = file_name + aquarium.save(update_fields=['svg_path']) + except Exception as e: + print(f"Error generating Aquarium SVG sync: {e}") + return aquarium + + @swagger_auto_schema( + operation_summary="내 아쿠아리움 상세 조회", + operation_description="현재 사용자의 개인 아쿠아리움 정보와 렌더링된 SVG URL을 반환합니다.", + tags=["Personal Aquarium"] + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + +class AquariumBackgroundUpdateView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="내 아쿠아리움 배경 변경", + operation_description="보유 중인 배경 중 하나를 선택하여 개인 아쿠아리움에 적용합니다.", + tags=["Personal Aquarium"], + request_body=BackgroundChangeSerializer, + responses={200: "성공적으로 변경됨", 404: "보유하지 않은 배경"} + ) + def post(self, request): + serializer = BackgroundChangeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + own_bg = get_object_or_404(OwnBackground, user=request.user, background_id=serializer.validated_data['background_id']) + aquarium, _ = Aquarium.objects.get_or_create(user=request.user) + aquarium.background = own_bg + aquarium.save() + async_task('apps.aquatics.tasks.generate_aquarium_svg_task', request.user.id) + return Response({"detail": "아쿠아리움 배경이 업데이트되었습니다."}) + +class AquariumFishVisibilityUpdateView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="내 아쿠아리움 물고기 배치 설정", + operation_description="개인 아쿠아리움에 노출할 물고기들을 벌크로 설정합니다.", + tags=["Personal Aquarium"], + request_body=FishVisibilityBulkUpdateSerializer, + responses={200: "배치 완료"} + ) + def post(self, request): + serializer = FishVisibilityBulkUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + fish_settings = serializer.validated_data['fish_settings'] + aquarium, _ = Aquarium.objects.get_or_create(user=request.user) + fish_ids = [item['id'] for item in fish_settings] + user_fishes = ContributionFish.objects.filter(id__in=fish_ids, contributor__user=request.user) + settings_map = {item['id']: item['visible'] for item in fish_settings} + with transaction.atomic(): + for fish in user_fishes: + visible = settings_map[fish.id] + fish.is_visible_in_aquarium = visible + fish.aquarium = aquarium if visible else None + fish.save() + async_task('apps.aquatics.tasks.generate_aquarium_svg_task', request.user.id) + return Response({"detail": "아쿠아리움 물고기 배치가 완료되었습니다."}) + +class FishtankDetailView(generics.RetrieveAPIView): + permission_classes = [IsAuthenticated] + serializer_class = FishtankDetailSerializer + + def get_object(self): + repo_id = self.kwargs.get('repo_id') + repository = get_object_or_404(Repository, id=repo_id) + fishtank, _ = Fishtank.objects.get_or_create(repository=repository, user=self.request.user) + if not fishtank.svg_path: + try: + svg_content = render_fishtank_svg(repository, self.request.user) + if svg_content: + file_name = f"fishtanks/repo_{repository.id}_user_{self.request.user.id}.svg" + path = os.path.join(settings.MEDIA_ROOT, file_name) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(svg_content) + fishtank.svg_path = file_name + fishtank.save(update_fields=['svg_path']) + except Exception as e: + print(f"Error generating Fishtank SVG sync: {e}") + return fishtank + + @swagger_auto_schema( + operation_summary="레포지토리 수족관 상세 조회", + operation_description="특정 레포지토리의 공용 수족관 정보를 조회합니다. (유저별 커스텀 배경 반영)", + tags=["Repository Fishtank"], + manual_parameters=[openapi.Parameter('repo_id', openapi.IN_PATH, description="레포지토리 ID", type=openapi.TYPE_INTEGER)] + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + +class FishtankBackgroundUpdateView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="레포지토리 수족관 배경 설정", + operation_description="특정 레포지토리 수족관을 볼 때 사용할 내 배경을 설정합니다.", + tags=["Repository Fishtank"], + request_body=BackgroundChangeSerializer, + responses={200: "성공적으로 설정됨"} + ) + def post(self, request, repo_id): + serializer = BackgroundChangeSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + own_bg = get_object_or_404(OwnBackground, user=request.user, background_id=serializer.validated_data['background_id']) + repository = get_object_or_404(Repository, id=repo_id) + fishtank, _ = Fishtank.objects.get_or_create(repository=repository, user=request.user) + fishtank.background = own_bg + fishtank.save() + async_task('apps.aquatics.tasks.generate_fishtank_svg_task', repository.id, request.user.id) + return Response({"detail": "수족관 배경 설정이 업데이트되었습니다."}) + +class FishtankFishVisibilityUpdateView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="레포지토리 수족관 내 내 물고기 노출 설정", + operation_description="공용 수족관에서 다른 유저들에게 내 물고기를 보여줄지 여부를 설정합니다.", + tags=["Repository Fishtank"], + request_body=FishVisibilityBulkUpdateSerializer, + responses={200: "설정 완료", 400: "권한 없음"} + ) + def post(self, request, repo_id): + serializer = FishVisibilityBulkUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + fish_settings = serializer.validated_data['fish_settings'] + fish_ids = [item['id'] for item in fish_settings] + user_fishes = ContributionFish.objects.filter(id__in=fish_ids, contributor__user=request.user, contributor__repository_id=repo_id) + if user_fishes.count() != len(set(fish_ids)): + return Response({"detail": "권한이 없는 물고기가 포함됨"}, status=400) + settings_map = {item['id']: item['visible'] for item in fish_settings} + with transaction.atomic(): + for fish in user_fishes: + fish.is_visible_in_fishtank = settings_map[fish.id] + fish.save() + related_fishtanks = Fishtank.objects.filter(repository_id=repo_id) + for ft in related_fishtanks: + async_task('apps.aquatics.tasks.generate_fishtank_svg_task', repo_id, ft.user_id) + return Response({"detail": "수족관 노출 설정 완료"}) + +class UserContributionFishListView(generics.ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = UserFishListSerializer + + @swagger_auto_schema( + operation_summary="내 보유 물고기 목록 조회", + operation_description="모든 레포지토리 기여를 통해 획득한 나의 모든 물고기 리스트를 조회합니다.", + tags=["Inventory"] + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return ContributionFish.objects.filter(contributor__user=self.request.user).select_related('fish_species', 'contributor__repository') + +class UserOwnBackgroundListView(generics.ListAPIView): permission_classes = [IsAuthenticated] + serializer_class = OwnBackgroundListSerializer + + @swagger_auto_schema( + operation_summary="내 보유 배경 목록 조회", + operation_description="상점 구매나 이벤트를 통해 해금한 나의 모든 배경 리스트를 조회합니다.", + tags=["Inventory"] + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return OwnBackground.objects.filter(user=self.request.user).select_related('background') + + +class AquariumFishVisibilityUpdateView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="내 아쿠아리움 물고기 배치 설정", + request_body=FishVisibilityBulkUpdateSerializer, + responses={200: "배치 완료"}, + tags=["Personal Aquarium"], + ) + def post(self, request): + serializer = FishVisibilityBulkUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + fish_settings = serializer.validated_data['fish_settings'] + aquarium, _ = Aquarium.objects.get_or_create(user=request.user) + + fish_ids = [item['id'] for item in fish_settings] + user_fishes = ContributionFish.objects.filter(id__in=fish_ids, contributor__user=request.user) + + settings_map = {item['id']: item['visible'] for item in fish_settings} + + with transaction.atomic(): + for fish in user_fishes: + visible = settings_map[fish.id] + fish.is_visible_in_aquarium = visible + fish.aquarium = aquarium if visible else None + fish.save() + # 개인 아쿠아리움 배치 변경 후 SVG 재생성 + async_task('apps.aquatics.tasks.generate_aquarium_svg_task', request.user.id) + + return Response({"detail": "아쿠아리움 물고기 배치가 완료되었습니다."}) + + + +# --- render 테스트용 뷰 --- +class AquariumSvgPreviewView(APIView): + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_summary="(DEBUG) 내 아쿠아리움 SVG 프리뷰 (저장 없이 즉시 반환)", + manual_parameters=[ + openapi.Parameter("as_text", openapi.IN_QUERY, type=openapi.TYPE_BOOLEAN, + description="true면 JSON으로 svg 문자열 반환, false면 image/svg+xml로 반환", default=False), + ], + responses={200: "SVG or JSON"}, + tags=["SVG Preview"], + ) def get(self, request): + svg = render_aquarium_svg(request.user) - return Response(svg, content_type="image/svg+xml") + + + logger.warning(f"[PREVIEW] svg_type={type(svg)} svg_len={(len(svg) if svg else 0)}") + + if request.query_params.get("as_text") in ["1", "true", "True"]: + return Response({"svg": svg}) + return HttpResponse(svg, content_type="image/svg+xml; charset=utf-8") + + +class FishtankSvgPreviewView(APIView): + permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_summary="(DEBUG) 특정 repo의 피시탱크 SVG 프리뷰 (저장 없이 즉시 반환)", + manual_parameters=[ + openapi.Parameter("as_text", openapi.IN_QUERY, type=openapi.TYPE_BOOLEAN, + description="true면 JSON으로 svg 문자열 반환, false면 image/svg+xml로 반환", default=False), + ], + tags=["SVG Preview"], + responses={200: "SVG or JSON"} + ) + def get(self, request, repo_id: int): + repo = Repository.objects.get(id=repo_id) + svg = render_fishtank_svg(repo, request.user) + if request.query_params.get("as_text") in ["1", "true", "True"]: + return Response({"svg": svg}) + + return HttpResponse(svg, content_type="image/svg+xml; charset=utf-8") + +class AquariumSvgPathView(APIView): + """ + 로그인 유저의 개인 Aquarium SVG path를 반환 + - SVG가 없으면 생성(task 호출) + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + username = user.username + + # GitHub에서 접근 가능한 render 도메인 + render_base = getattr( + settings, + "RENDER_DOMAIN", + "https://render.your-service.org", + ) + + img_url = ( + f"{render_base}/render/aquarium/{username}" + "?width=700&height=400" + ) + + profile_url = f"https://your-service.org/u/{username}" + + return Response({ + "ok": True, + "user": { + "username": username, + }, + "img_url": img_url, + "html": f"""""", + "markdown": f"""[![{username}'s Aquarium]({img_url})]({profile_url})""", + }) +# path 복사 리드미에 넣을 용도 +class AquariumEmbedCodeView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + username = request.user.username + render_base = settings.RENDER_DOMAIN + + img_url = f"{render_base}/render/aquarium/{username}?width=700&height=400" + profile_url = f"https://githubaquarium.store/u/{username}" + + return Response({ + "ok": True, + "img_url": img_url, + "html": f'', + "markdown": f'[![{username}\'s Aquarium]({img_url})]({profile_url})', + }) + + +class FishtankEmbedCodeView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, repo_id: int): + repo = Repository.objects.filter(id=repo_id).first() + if not repo: + return Response({"ok": False, "detail": "Repository not found"}, status=404) + + username = request.user.username + render_base = settings.RENDER_DOMAIN + + img_url = f"{render_base}/render/fishtank/{username}/{repo.id}?width=700&height=400" + link_url = f"https://githubaquarium.store/repo/{repo.id}" + + return Response({ + "ok": True, + "img_url": img_url, + "html": f'', + "markdown": f'[![{repo.full_name}]({img_url})]({link_url})', + }) \ No newline at end of file diff --git a/apps/aquatics/views_aquarium.py b/apps/aquatics/views_aquarium.py deleted file mode 100644 index 37ff57e..0000000 --- a/apps/aquatics/views_aquarium.py +++ /dev/null @@ -1,311 +0,0 @@ -#aquatics/views_aquarium.py -from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response - -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -from django.http import HttpResponse -from apps.aquatics.models import ContributionFish, OwnBackground -from apps.aquatics.serializers_aquarium import ( - AquariumDetailSerializer, - AquariumFishSerializer, - AquariumBackgroundSerializer, -) -from apps.aquatics.renderer.tank import render_aquarium_svg - - -# ---------------------------------------------------------- -# 1) 아쿠아리움 전체 조회 -# ---------------------------------------------------------- -class AquariumDetailView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움 상세 조회", - responses={200: AquariumDetailSerializer()} - ) - def get(self, request): - aquarium = request.user.aquarium - fishes = ContributionFish.objects.filter(aquarium=aquarium) - - serializer = AquariumDetailSerializer( - aquarium, context={"fishes": fishes} - ) - serializer._data["fishes"] = AquariumFishSerializer(fishes, many=True).data - return Response(serializer.data, status=200) - - -# ---------------------------------------------------------- -# 2) 내가 가진 물고기 전체 조회 (언락된 모든 물고기) -# ---------------------------------------------------------- -class MyUnlockedFishListView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="유저가 언락한 모든 물고기 조회", - responses={200: AquariumFishSerializer(many=True)} - ) - def get(self, request): - fishes = ContributionFish.objects.filter( - contributor__user=request.user - ) - data = AquariumFishSerializer(fishes, many=True).data - return Response(data, status=200) - - -# ---------------------------------------------------------- -# 3) 아쿠아리움에 물고기 추가 -# ---------------------------------------------------------- -class AquariumAddFishView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움에 물고기 추가", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "contribution_fish_id": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="ContributionFish ID" - ) - }, - required=["contribution_fish_id"] - ), - responses={200: "Added"} - ) - def post(self, request): - cf_id = request.data.get("contribution_fish_id") - aquarium = request.user.aquarium - - try: - cf = ContributionFish.objects.get(id=cf_id) - except ContributionFish.DoesNotExist: - return Response({"detail": "Fish not found"}, status=404) - - cf.aquarium = aquarium - cf.save() - return Response({"detail": "Added to aquarium"}, status=200) - - -# ---------------------------------------------------------- -# 4) 아쿠아리움 물고기 제거 -# ---------------------------------------------------------- -class AquariumRemoveFishView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움 물고기 제거", - responses={200: "Removed"} - ) - def delete(self, request, fish_id): - try: - cf = ContributionFish.objects.get(id=fish_id, aquarium=request.user.aquarium) - except ContributionFish.DoesNotExist: - return Response({"detail": "Not found"}, status=404) - - cf.aquarium = None - cf.save() - return Response({"detail": "Removed"}, status=200) - - -# ---------------------------------------------------------- -# 5) 아쿠아리움 배경 목록 조회 -# ---------------------------------------------------------- -class AquariumBackgroundListView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움 배경 목록 조회", - responses={200: AquariumBackgroundSerializer(many=True)} - ) - def get(self, request): - owned = OwnBackground.objects.filter(user=request.user) - data = AquariumBackgroundSerializer(owned, many=True).data - return Response(data, status=200) - - -# ---------------------------------------------------------- -# 6) 아쿠아리움 배경 적용 -# ---------------------------------------------------------- -class AquariumApplyBackgroundView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움 배경 적용", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={"own_background_id": openapi.Schema(type=openapi.TYPE_INTEGER)}, - required=["own_background_id"] - ), - responses={200: "Applied"} - ) - def post(self, request): - bg_id = request.data.get("own_background_id") - - try: - bg = OwnBackground.objects.get(id=bg_id, user=request.user) - except OwnBackground.DoesNotExist: - return Response({"detail": "Background not owned"}, status=400) - - aquarium = request.user.aquarium - aquarium.background = bg - aquarium.save() - - return Response({"detail": "Background applied"}, status=200) - - -# ---------------------------------------------------------- -# 7) Export 저장 (scale/offset) -# ---------------------------------------------------------- -''' -class AquariumExportView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움 Export 저장", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "scale": openapi.Schema(type=openapi.TYPE_NUMBER), - "offset_x": openapi.Schema(type=openapi.TYPE_NUMBER), - "offset_y": openapi.Schema(type=openapi.TYPE_NUMBER), - }, - required=["scale"] - ), - responses={200: "Saved"} - ) - def post(self, request): - # 모델 확장 시 여기에 저장 로직 추가 - return Response({"detail": "Saved"}, status=200) -''' - -# ---------------------------------------------------------- -# 8) SVG 렌더링 -# ---------------------------------------------------------- -class AquariumSVGView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움 SVG 렌더링", - responses={200: "SVG XML"} - ) - def get(self, request): - svg = render_aquarium_svg(request.user) - return HttpResponse(svg, content_type="image/svg+xml") - - -# 9) 아쿠아리움 선택 가능한 물고기 목록 -class AquariumSelectableFishView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="선택 가능한 물고기 목록 조회", - operation_description="유저가 보유한 모든 ContributionFish를 selectable 형태로 반환합니다.", - responses={200: "Selectable fish list"} - ) - def get(self, request): - user = request.user - aquarium = user.aquarium - - fishes = ContributionFish.objects.filter(contributor__user=user) \ - .select_related("fish_species", "contributor__repository") - - data = [] - for f in fishes: - is_in_aquarium = (f.aquarium_id == aquarium.id) - data.append({ - "id": f.id, - "species": f.fish_species.name, - "repo_name": f.contributor.repository.name, - "selected": is_in_aquarium and f.is_visible_in_aquarium - }) - - return Response({"fishes": data}, status=200) -# 10) Export: 프론트 선택 상태를 실제 DB에 반영 -class AquariumExportSelectionView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움 Export - 선택된 물고기 저장", - operation_description="프론트에서 최종 선택된 물고기 ID 목록을 받아 DB에 반영합니다.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "fish_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER) - ) - }, - required=["fish_ids"] - ), - responses={200: "Saved"} - ) - def post(self, request): - user = request.user - aquarium = user.aquarium - selected_ids = request.data.get("fish_ids", []) - - # 1) 유저가 소유한 모든 fish 가져오기 - all_my_fish = ContributionFish.objects.filter(contributor__user=user) - - # 2) 선택되지 않은 물고기 → aquarium에서 제거 - all_my_fish.exclude(id__in=selected_ids).update(aquarium=None) - - # 3) 선택된 물고기 → aquarium에 추가하고 보이게 설정 - all_my_fish.filter(id__in=selected_ids).update(aquarium=aquarium, is_visible_in_aquarium=True) - - return Response({"detail": "Aquarium updated"}, status=200) - - -class AquariumSpriteListView(APIView): - """ - 프론트 FishTankTest + FishSpriteTest에 맞는 데이터 포맷: - - background_url: 현재 아쿠아리움 배경 이미지 - - fishes: [{ id, label, svg_source }, ...] - """ - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="아쿠아리움용 스프라이트 리스트", - operation_description=( - "프론트에서 FishTankTest / FishSpriteTest로 바로 사용할 수 있도록 " - "배경 이미지와 각 물고기의 스프라이트 SVG를 반환합니다." - ), - responses={200: "background_url + fishes 배열"} - ) - def get(self, request): - user = request.user - aquarium: Aquarium = user.aquarium - - # 배경 URL - if aquarium.background and aquarium.background.background.background_image: - bg_url = aquarium.background.background.background_image.url - else: - bg_url = "" - - # 이 유저 아쿠아리움에 들어있는 물고기들 - fishes = ( - ContributionFish.objects - .filter(aquarium=aquarium, is_visible_in_aquarium=True) - .select_related("fish_species", "contributor__repository", "contributor__user") - ) - - fish_list = [] - for cf in fishes: - # 프론트에서 label 로 쓸 텍스트 (원하는 쪽 골라서 사용) - # 예시 1: 레포지토리 이름 - # label_text = cf.contributor.repository.name - # 예시 2: 기여자 username - label_text = cf.contributor.user.username - - fish_list.append({ - "id": cf.id, - "label": label_text, - "svg_source": cf.fish_species.svg_template, - }) - - return Response({ - "background_url": bg_url, - "fishes": fish_list, - }, status=200) \ No newline at end of file diff --git a/apps/aquatics/views_fishtank.py b/apps/aquatics/views_fishtank.py deleted file mode 100644 index ebb1cfa..0000000 --- a/apps/aquatics/views_fishtank.py +++ /dev/null @@ -1,368 +0,0 @@ -#aquatics/views_fishtank.py -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated - -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -from django.http import HttpResponse -from apps.repositories.models import Repository -from apps.aquatics.models import Fishtank, FishtankSetting, OwnBackground,ContributionFish -from apps.aquatics.serializers_fishtank import ( - FishtankDetailSerializer, -) -from apps.aquatics.serializers_aquarium import ( - AquariumBackgroundSerializer, -) -from apps.aquatics.renderer.tank import render_fishtank_svg -# ---------------------------------------------------------- -# 1) Fishtank 상세 조회 -# ---------------------------------------------------------- -class FishtankDetailView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="피쉬탱크 상세 조회", - operation_description="레포지토리 ID를 기반으로 피쉬탱크 내부 정보(기여자, 물고기)를 조회합니다.", - responses={200: FishtankDetailSerializer} - ) - def get(self, request, repo_id): - try: - repository = Repository.objects.get(id=repo_id) - except Repository.DoesNotExist: - return Response({"detail": "Repository not found"}, status=404) - - # Fishtank가 없으면 자동으로 생성 - fishtank, created = Fishtank.objects.get_or_create( - repository=repository, - defaults={'svg_path': ''} - ) - - serializer = FishtankDetailSerializer(fishtank) - return Response(serializer.data, status=200) - - -# ---------------------------------------------------------- -# 2) Fishtank SVG -# ---------------------------------------------------------- -class FishtankSVGView(APIView): - - permission_classes = [IsAuthenticated] - @swagger_auto_schema( - operation_summary="피쉬탱크 SVG 렌더링", - operation_description="특정 Repository의 fishtank를 하나의 SVG로 렌더링합니다.", - responses={200: "SVG XML String"}, - ) - def get(self, request, repo_id): - try: - repository = Repository.objects.get(id=repo_id) - except Repository.DoesNotExist: - return Response({"detail": "Repository not found"}, status=404) - - svg = render_fishtank_svg(repository) - return HttpResponse(svg, content_type="image/svg+xml") - -# ---------------------------------------------------------- -# 3) 유저가 소유한 fishtank 배경 목록 -# ---------------------------------------------------------- -class FishtankBackgroundListView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="피쉬탱크 배경 목록 조회", - operation_description="유저가 보유한 배경(OwnBackground)의 원본 Background 데이터를 반환합니다.", - responses={200: AquariumBackgroundSerializer(many=True)} - ) - def get(self, request): - owned = OwnBackground.objects.filter(user=request.user) - # AquariumBackgroundSerializer는 OwnBackground를 직렬화하므로 owned를 직접 사용 - serializer = AquariumBackgroundSerializer(owned, many=True) - return Response(serializer.data, status=200) - - -# ---------------------------------------------------------- -# 4) 피쉬탱크 배경 적용 -# ---------------------------------------------------------- -class ApplyFishtankBackgroundView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="피쉬탱크 배경 적용", - operation_description="사용자가 소유한 OwnBackground 중 하나를 fishtank 배경으로 적용합니다.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "background_id": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="유저가 소유한 OwnBackground.background.id" - ), - }, - required=["background_id"] - ), - responses={ - 200: openapi.Response("Background applied"), - 400: "Bad Request", - 404: "Not Found", - }, - ) - - def post(self, request, repo_id): - bg_id = request.data.get("background_id") - - try: - repository = Repository.objects.get(id=repo_id) - fishtank = repository.fishtank - except Repository.DoesNotExist: - return Response({"detail": "Repository not found"}, status=404) - - try: - owned_bg = OwnBackground.objects.get( - user=request.user, background_id=bg_id - ) - except OwnBackground.DoesNotExist: - return Response({"detail": "Background not owned"}, status=400) - - setting, _ = FishtankSetting.objects.update_or_create( - fishtank=fishtank, - contributor=request.user, - defaults={"background": owned_bg}, - ) - - return Response({"detail": "Background applied"}, status=200) - - -# ---------------------------------------------------------- -# 5) Fishtank Export (scale, offset 저장) -# ---------------------------------------------------------- -''' -class FishtankExportView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="피쉬탱크 Export (scale/offset 저장)", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "scale": openapi.Schema(type=openapi.TYPE_NUMBER), - "offset_x": openapi.Schema(type=openapi.TYPE_NUMBER), - "offset_y": openapi.Schema(type=openapi.TYPE_NUMBER), - }, - required=["scale"] - ), - responses={200: "Saved"} - ) - - def post(self, request, repo_id): - - # 저장 필드가 모델에 아직 없기 때문에, 저장 로직은 후에 추가 가능 - return Response({"detail": "Saved"}, status=200) -''' -# 9) 피쉬탱크 선택 가능한 물고기 목록 -class FishtankSelectableFishView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="특정 Repository의 Fishtank 물고기 목록 조회", - operation_description=( - "repository_id에 해당하는 Fishtank에서 기여자 기반으로 생성된 " - "ContributionFish 목록을 반환합니다.\n\n" - "각 객체는 물고기의 식별자(id), 기여자 username, " - "물고기 species 이름, commit_count, 그리고 사용자가 현재 FishTank에 " - "표시되도록 선택했는지 여부(selected)를 포함합니다." - ), - manual_parameters=[ - openapi.Parameter( - 'repo_id', - openapi.IN_PATH, - description="Repository ID", - type=openapi.TYPE_INTEGER, - required=True, - ) - ], - responses={ - 200: openapi.Response( - description="물고기 목록 반환", - examples={ - "application/json": { - "fishes": [ - { - "id": 12, - "username": "alice", - "species": "Salmon", - "commit_count": 34, - "selected": True - }, - { - "id": 13, - "username": "bob", - "species": "Goldfish", - "commit_count": 12, - "selected": False - } - ] - } - } - ), - 404: openapi.Response( - description="Fishtank not found", - examples={ - "application/json": {"detail": "Fishtank not found"} - } - ) - } - ) - def get(self, request, repo_id): - # Repository 존재 확인 - try: - repository = Repository.objects.get(id=repo_id) - except Repository.DoesNotExist: - return Response({"detail": "Repository not found"}, status=404) - - # Fishtank가 없으면 자동으로 생성 - fishtank, created = Fishtank.objects.get_or_create( - repository=repository, - defaults={'svg_path': ''} - ) - - fishes = ContributionFish.objects.filter( - contributor__repository_id=repo_id - ).select_related("fish_species", "contributor__user") - - # 가장 높은 maturity를 가진 물고기의 group_code 찾기 - highest_group_code = None - if fishes.exists(): - highest_fish = max(fishes, key=lambda f: f.fish_species.maturity) - highest_group_code = highest_fish.fish_species.group_code - - # 같은 group_code를 가진 모든 FishSpecies 가져오기 (할당되지 않은 것도 포함) - from apps.items.models import FishSpecies - all_group_fishes = [] - if highest_group_code: - all_group_fishes = FishSpecies.objects.filter( - group_code=highest_group_code - ).order_by('maturity') - - data = [] - # 실제로 할당된 물고기들 추가 - for f in fishes: - data.append({ - "id": f.id, - "username": f.contributor.user.username, - "species": f.fish_species.name, - "commit_count": f.contributor.commit_count, - "selected": f.is_visible_in_fishtank, - "maturity": f.fish_species.maturity, - "required_commits": f.fish_species.required_commits, - "group_code": f.fish_species.group_code, - "is_assigned": True, # 실제로 할당된 물고기 - }) - - # 같은 group_code를 가진 할당되지 않은 maturity 단계들도 추가 - assigned_maturities = {f.fish_species.maturity for f in fishes if f.fish_species.group_code == highest_group_code} - for fs in all_group_fishes: - if fs.maturity not in assigned_maturities: - # 할당되지 않은 maturity 단계는 기본 정보만 포함 - data.append({ - "id": None, # 할당되지 않았으므로 ID 없음 - "username": None, - "species": fs.name, - "commit_count": 0, - "selected": False, - "maturity": fs.maturity, - "required_commits": fs.required_commits, - "group_code": fs.group_code, - "is_assigned": False, # 할당되지 않은 물고기 - }) - - return Response({"fishes": data}, status=200) -# 10) 피쉬탱크 Export → 선택 상태 실제 저장 -class FishtankExportSelectionView(APIView): - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="피쉬탱크 Export - 선택된 물고기 적용", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "fish_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER) - ) - }, - required=["fish_ids"] - ), - responses={200: "Saved"} - ) - def post(self, request, repo_id): - selected_ids = request.data.get("fish_ids", []) - - try: - Fishtank.objects.get(repository_id=repo_id) - except Fishtank.DoesNotExist: - return Response({"detail": "Fishtank not found"}, status=404) - - fishes = ContributionFish.objects.filter( - contributor__repository_id=repo_id - ) - - # 1) 선택되지 않은 물고기 → 숨김 - fishes.exclude(id__in=selected_ids).update(is_visible_in_fishtank=False) - - # 2) 선택된 물고기 → 표시 - fishes.filter(id__in=selected_ids).update(is_visible_in_fishtank=True) - - return Response({"detail": "Fishtank updated"}, status=200) - -class FishtankSpriteListView(APIView): - """ - 특정 레포지토리의 fishtank를 프론트 FishTankTest / FishSpriteTest로 그리기 위한 데이터. - """ - permission_classes = [IsAuthenticated] - - @swagger_auto_schema( - operation_summary="피쉬탱크용 스프라이트 리스트", - operation_description=( - "repo_id에 해당하는 Fishtank에서 표시 상태(is_visible_in_fishtank=True)인 " - "물고기들의 스프라이트 SVG와 라벨을 반환합니다." - ), - responses={200: "background_url + fishes 배열"} - ) - def get(self, request, repo_id): - try: - repository = Repository.objects.get(id=repo_id) - fishtank = repository.fishtank - except Repository.DoesNotExist: - return Response({"detail": "Repository not found"}, status=404) - except Fishtank.DoesNotExist: - return Response({"detail": "Fishtank not found"}, status=404) - - # 피쉬탱크 배경: FishtankSetting 에서 현재 유저가 설정한 배경 1개만 쓴다고 가정 - setting = fishtank.settings.select_related("background__background").filter( - contributor=request.user - ).first() - - if setting and setting.background and setting.background.background.background_image: - bg_url = setting.background.background.background_image.url - else: - bg_url = "" - - fishes = ( - ContributionFish.objects - .filter(contributor__repository=repository, is_visible_in_fishtank=True) - .select_related("fish_species", "contributor__user") - ) - - fish_list = [] - for cf in fishes: - label_text = cf.contributor.user.username # 혹은 repository.name 등 - - fish_list.append({ - "id": cf.id, - "label": label_text, - "svg_source": cf.fish_species.svg_template, - }) - - return Response({ - "background_url": bg_url, - "fishes": fish_list, - }, status=200) \ No newline at end of file diff --git a/apps/aquatics/views_render.py b/apps/aquatics/views_render.py new file mode 100644 index 0000000..bc89a4d --- /dev/null +++ b/apps/aquatics/views_render.py @@ -0,0 +1,68 @@ +# apps/aquatics/views_render.py +from django.http import HttpResponse +from django.contrib.auth import get_user_model +from rest_framework.views import APIView +from apps.aquatics.renderers import render_aquarium_svg +from apps.repositories.models import Repository +from apps.aquatics.renderers import render_fishtank_svg + +User = get_user_model() + + +class PublicAquariumSvgRenderView(APIView): + """ + GitHub README용 Aquarium SVG 렌더 + - 로그인 필요 없음 + - SVG 직접 반환 + """ + authentication_classes = [] + permission_classes = [] + + def get(self, request, username: str): + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + return HttpResponse( + "", + content_type="image/svg+xml", + status=404, + ) + + width = int(request.GET.get("width", 700)) + height = int(request.GET.get("height", 400)) + + svg = render_aquarium_svg(user, width=width, height=height) + + return HttpResponse( + svg, + content_type="image/svg+xml; charset=utf-8", + ) + +class PublicFishtankSvgRenderView(APIView): + """ + GitHub README용 Fishtank SVG 렌더 + """ + authentication_classes = [] + permission_classes = [] + + def get(self, request, username: str, repo_id: int): + try: + user = User.objects.get(username=username) + repo = Repository.objects.get(id=repo_id) + except (User.DoesNotExist, Repository.DoesNotExist): + return HttpResponse( + "", + content_type="image/svg+xml", + status=404, + ) + + width = int(request.GET.get("width", 700)) + height = int(request.GET.get("height", 400)) + + svg = render_fishtank_svg(repo, user, width=width, height=height) + + return HttpResponse( + svg, + content_type="image/svg+xml; charset=utf-8", + ) + diff --git a/apps/items/admin.py b/apps/items/admin.py index 13d108f..9501065 100644 --- a/apps/items/admin.py +++ b/apps/items/admin.py @@ -1,14 +1,24 @@ from django.contrib import admin -from .models import FishSpecies, Background +from .models import FishSpecies, Background, Item @admin.register(FishSpecies) class FishSpeciesAdmin(admin.ModelAdmin): - list_display = ('name', 'group_code', 'maturity', 'required_commits', 'rarity') + list_display = ('name', 'group_code', 'maturity', 'rarity', 'required_commits') list_filter = ('rarity', 'maturity', 'group_code') search_fields = ('name', 'group_code') ordering = ('group_code', 'maturity') @admin.register(Background) class BackgroundAdmin(admin.ModelAdmin): - list_display = ('name', 'code') + list_display = ('name', 'code', 'has_image') + search_fields = ('name', 'code') + + def has_image(self, obj): + return bool(obj.background_image) + has_image.boolean = True + +@admin.register(Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ('name', 'code', 'item_type', 'price', 'is_active') + list_filter = ('item_type', 'is_active') search_fields = ('name', 'code') \ No newline at end of file diff --git a/apps/items/management/commands/init_items.py b/apps/items/management/commands/init_items.py new file mode 100644 index 0000000..15bf75e --- /dev/null +++ b/apps/items/management/commands/init_items.py @@ -0,0 +1,113 @@ +import os +import logging +from django.core.management.base import BaseCommand +from django.conf import settings +from django.core.files import File +from apps.items.models import FishSpecies, Background, Item + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + help = 'fixtures 폴더를 기반으로 FishSpecies, Background, 그리고 상점 Item을 초기화합니다.' + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('=== 데이터 초기화 시작 ===')) + + # 1. 경로 설정 + base_fixtures_path = settings.BASE_DIR / 'fixtures' + templates_path = base_fixtures_path / 'templates' + backgrounds_path = base_fixtures_path / 'backgrounds' + + # 2. FishSpecies 초기화 (SVG Templates) + if templates_path.exists(): + self.stdout.write('1. FishSpecies 생성 중...') + for svg_file in templates_path.glob('*.svg'): + filename = svg_file.stem # 예: ShrimpWich_1 + + try: + if '_' in filename: + group_code, maturity_str = filename.rsplit('_', 1) + maturity = int(maturity_str) + else: + group_code = filename + maturity = 1 + + with open(svg_file, 'r', encoding='utf-8') as f: + svg_content = f.read() + + # 진화 단계별 요구 커밋 수 (테스트를 위해 낮게 설정) + req_commits = (maturity - 1) * 50 + + FishSpecies.objects.update_or_create( + group_code=group_code, + maturity=maturity, + defaults={ + 'name': f"{group_code} Lv.{maturity}", + 'required_commits': req_commits, + 'rarity': FishSpecies.Rarity.COMMON, + 'svg_template': svg_content + } + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f" - {filename} 처리 실패: {e}")) + self.stdout.write(self.style.SUCCESS(' - FishSpecies 완료')) + else: + self.stdout.write(self.style.WARNING(' - templates 폴더가 없어 FishSpecies 스킵')) + + + # 3. Background 및 관련 Shop Item 초기화 + if backgrounds_path.exists(): + self.stdout.write('2. Background 및 배경 상품 생성 중...') + for img_file in backgrounds_path.glob('*'): + if img_file.suffix.lower() in ['.png', '.jpg', '.jpeg']: + raw_name = img_file.stem + # 파일명에서 코드 추출 (예: deep-sea_bg -> DEEP_SEA) + clean_name = raw_name.split('_')[0] + code = clean_name.upper().replace('-', '_') + + try: + with open(img_file, 'rb') as f: + # 3-1. 배경 생성 + bg_obj, created = Background.objects.update_or_create( + code=code, + defaults={ + 'name': clean_name.replace('-', ' ').title(), + 'background_image': File(f, name=img_file.name) + } + ) + + # 3-2. 해당 배경을 상점 아이템(해금권)으로 등록 + Item.objects.update_or_create( + code=f"BG_{code}", + defaults={ + 'name': f"{bg_obj.name} 배경 해금권", + 'description': f"'{bg_obj.name}' 배경을 내 수족관에 적용할 수 있습니다.", + 'item_type': Item.ItemType.BG_UNLOCK, + 'target_background': bg_obj, + 'price': 100, # 배경 가격 + 'is_active': True + } + ) + self.stdout.write(f" - 배경 및 상품 등록: {code}") + except Exception as e: + self.stdout.write(self.style.ERROR(f" - {raw_name} 처리 실패: {e}")) + self.stdout.write(self.style.SUCCESS(' - Background & Items 완료')) + else: + self.stdout.write(self.style.WARNING(' - backgrounds 폴더가 없어 Background 스킵')) + + + # 4. 소모품 아이템 초기화 (리롤권) + self.stdout.write('3. 소모품 상품 생성 중...') + Item.objects.update_or_create( + code="TICKET_REROLL", + defaults={ + 'name': "물고기 리롤권", + 'description': "물고기의 종류(패밀리)를 랜덤하게 변경합니다. 등급과 성장 단계는 유지될 수 있습니다.", + 'item_type': Item.ItemType.REROLL_TICKET, + 'price': 50, + 'is_active': True + } + ) + self.stdout.write(self.style.SUCCESS(' - 리롤권 상품 등록 완료')) + + self.stdout.write(self.style.SUCCESS('=== 모든 데이터 초기화 완료 ===')) \ No newline at end of file diff --git a/apps/items/migrations/0001_initial.py b/apps/items/migrations/0001_initial.py index 6d8c886..ba58749 100644 --- a/apps/items/migrations/0001_initial.py +++ b/apps/items/migrations/0001_initial.py @@ -1,6 +1,8 @@ -# Generated by Django 5.2.6 on 2025-11-15 00:41 +# Generated by Django 4.2.27 on 2025-12-19 07:15 +import django.core.validators from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -17,7 +19,21 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='The display name of the background.', max_length=100, unique=True)), ('code', models.CharField(db_index=True, help_text='A short, unique code for this background.', max_length=10, unique=True)), - ('svg_template', models.TextField(help_text='The SVG source code template for this background.')), + ('background_image', models.ImageField(blank=True, help_text='배경 이미지 파일(PNG/JPG).', null=True, upload_to='backgrounds/', validators=[django.core.validators.FileExtensionValidator(['png', 'jpg', 'jpeg'])])), + ], + ), + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(help_text='아이템 식별 코드 (예: TICKET_REROLL)', max_length=50, unique=True)), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('item_type', models.CharField(choices=[('REROLL', 'Re-roll Ticket'), ('BG_UNLOCK', 'Background Unlock')], max_length=20)), + ('price', models.PositiveIntegerField(default=10, help_text='구매 가격 (Points)')), + ('image', models.ImageField(blank=True, null=True, upload_to='items/')), + ('is_active', models.BooleanField(default=True, help_text='상점 노출 여부')), + ('target_background', models.ForeignKey(blank=True, help_text='배경 해금권일 경우 연결된 배경 객체', null=True, on_delete=django.db.models.deletion.SET_NULL, to='items.background')), ], ), migrations.CreateModel( @@ -26,7 +42,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='The display name of the fish species.', max_length=100, unique=True)), ('group_code', models.CharField(db_index=True, default='NONE', help_text="A code for the evolution group, e.g., 'C-KRAKEN'.", max_length=10)), - ('maturity', models.IntegerField(choices=[(0, 'Hatchling'), (1, 'Juvenile'), (2, 'Youngling'), (3, 'Adult'), (4, 'Advanced'), (5, 'Master')], default=0, help_text='The evolution stage of the fish.')), + ('maturity', models.IntegerField(choices=[(1, 'Hatchling'), (2, 'Juvenile'), (3, 'Youngling'), (4, 'Adult'), (5, 'Advanced'), (6, 'Master')], default=1, help_text='The evolution stage of the fish.')), ('required_commits', models.PositiveIntegerField(db_index=True, default=0, help_text='The total number of commits required to reach this evolution stage.')), ('rarity', models.IntegerField(choices=[(1, 'Common'), (2, 'Uncommon'), (3, 'Rare'), (4, 'Epic'), (5, 'Legendary')], default=1, help_text='The rarity level of the fish species.')), ('svg_template', models.TextField(help_text='The SVG source code template for this fish.')), diff --git a/apps/items/migrations/0002_remove_background_svg_template_and_more.py b/apps/items/migrations/0002_remove_background_svg_template_and_more.py deleted file mode 100644 index dde5d34..0000000 --- a/apps/items/migrations/0002_remove_background_svg_template_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.6 on 2025-11-22 05:46 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('items', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='background', - name='svg_template', - ), - migrations.AddField( - model_name='background', - name='background_image', - field=models.ImageField(blank=True, help_text='배경 이미지 파일(PNG/JPG).', null=True, upload_to='backgrounds/', validators=[django.core.validators.FileExtensionValidator(['png', 'jpg', 'jpeg'])]), - ), - ] diff --git a/apps/items/migrations/0003_item.py b/apps/items/migrations/0003_item.py deleted file mode 100644 index 57cb939..0000000 --- a/apps/items/migrations/0003_item.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.27 on 2025-12-18 05:04 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('items', '0002_remove_background_svg_template_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='Item', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.CharField(help_text='아이템 식별 코드 (예: TICKET_REROLL)', max_length=50, unique=True)), - ('name', models.CharField(max_length=100)), - ('description', models.TextField(blank=True)), - ('item_type', models.CharField(choices=[('REROLL', 'Re-roll Ticket'), ('BG_UNLOCK', 'Background Unlock')], max_length=20)), - ('price', models.PositiveIntegerField(default=10, help_text='구매 가격 (Points)')), - ('image', models.ImageField(blank=True, null=True, upload_to='items/')), - ('is_active', models.BooleanField(default=True, help_text='상점 노출 여부')), - ('target_background', models.ForeignKey(blank=True, help_text='배경 해금권일 경우 연결된 배경 객체', null=True, on_delete=django.db.models.deletion.SET_NULL, to='items.background')), - ], - ), - ] diff --git a/apps/items/tests.py b/apps/items/tests.py deleted file mode 100644 index 4929020..0000000 --- a/apps/items/tests.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Create your tests here. diff --git a/apps/items/views.py b/apps/items/views.py deleted file mode 100644 index b8e4ee0..0000000 --- a/apps/items/views.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Create your views here. diff --git a/apps/repositories/admin.py b/apps/repositories/admin.py index 898a880..2d5b4be 100644 --- a/apps/repositories/admin.py +++ b/apps/repositories/admin.py @@ -3,20 +3,29 @@ @admin.register(Repository) class RepositoryAdmin(admin.ModelAdmin): - list_display = ('full_name', 'owner', 'language', 'stargazers_count', 'commit_count', 'last_synced_at') - list_filter = ('language',) - search_fields = ('full_name', 'owner__username') - ordering = ('-stargazers_count',) + list_display = ('full_name', 'owner', 'commit_count', 'stargazers_count', 'language', 'dirty_at', 'last_synced_at') + list_filter = ('language', 'default_branch') + search_fields = ('name', 'full_name', 'owner__username') + readonly_fields = ('last_synced_at',) # 시스템에 의해 자동 업데이트되는 필드 + + fieldsets = ( + ('Basic Info', {'fields': ('github_id', 'name', 'full_name', 'owner', 'html_url', 'description')}), + ('Stats & Sync', {'fields': ('stargazers_count', 'language', 'commit_count', 'default_branch', 'last_synced_hash', 'dirty_at', 'last_synced_at')}), + ) @admin.register(Contributor) class ContributorAdmin(admin.ModelAdmin): list_display = ('user', 'repository', 'commit_count') + list_filter = ('repository__language',) search_fields = ('user__username', 'repository__full_name') - ordering = ('-commit_count',) @admin.register(Commit) class CommitAdmin(admin.ModelAdmin): - list_display = ('sha', 'repository', 'author', 'author_name', 'committed_at') - list_filter = ('repository',) - search_fields = ('sha', 'repository__full_name', 'author__username', 'author_name') - ordering = ('-committed_at',) \ No newline at end of file + list_display = ('sha_short', 'repository', 'author', 'committed_at') + list_filter = ('committed_at', 'repository__language') + search_fields = ('sha', 'message', 'author_name', 'author_email') + readonly_fields = ('committed_at',) + + def sha_short(self, obj): + return obj.sha[:7] + sha_short.short_description = 'SHA' \ No newline at end of file diff --git a/apps/repositories/migrations/0001_initial.py b/apps/repositories/migrations/0001_initial.py index 80c7f34..e36a827 100644 --- a/apps/repositories/migrations/0001_initial.py +++ b/apps/repositories/migrations/0001_initial.py @@ -1,7 +1,5 @@ -# Generated by Django 5.2.6 on 2025-11-15 00:41 +# Generated by Django 4.2.27 on 2025-12-19 07:15 -import django.db.models.deletion -from django.conf import settings from django.db import migrations, models @@ -10,28 +8,9 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ - migrations.CreateModel( - name='Repository', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('github_id', models.BigIntegerField(unique=True)), - ('name', models.CharField(max_length=255)), - ('full_name', models.CharField(max_length=512)), - ('description', models.TextField(blank=True, null=True)), - ('html_url', models.URLField(max_length=512)), - ('stargazers_count', models.IntegerField(default=0)), - ('language', models.CharField(blank=True, max_length=100, null=True)), - ('commit_count', models.PositiveIntegerField(default=0, help_text='The total number of commits in this repository.')), - ('created_at', models.DateTimeField()), - ('updated_at', models.DateTimeField()), - ('last_synced_at', models.DateTimeField(auto_now=True)), - ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_repositories', to=settings.AUTH_USER_MODEL)), - ], - ), migrations.CreateModel( name='Commit', fields=[ @@ -41,8 +20,6 @@ class Migration(migrations.Migration): ('committed_at', models.DateTimeField()), ('author_name', models.CharField(max_length=255)), ('author_email', models.EmailField(max_length=254)), - ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='commits', to=settings.AUTH_USER_MODEL)), - ('repository', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commits', to='repositories.repository')), ], ), migrations.CreateModel( @@ -50,11 +27,26 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('commit_count', models.IntegerField()), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributions', to=settings.AUTH_USER_MODEL)), - ('repository', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributors', to='repositories.repository')), ], - options={ - 'unique_together': {('repository', 'user')}, - }, + ), + migrations.CreateModel( + name='Repository', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('github_id', models.BigIntegerField(unique=True)), + ('name', models.CharField(max_length=255)), + ('full_name', models.CharField(max_length=512)), + ('description', models.TextField(blank=True, null=True)), + ('html_url', models.URLField(max_length=512)), + ('stargazers_count', models.IntegerField(default=0)), + ('language', models.CharField(blank=True, max_length=100, null=True)), + ('commit_count', models.PositiveIntegerField(default=0, help_text='The total number of commits in this repository.')), + ('created_at', models.DateTimeField()), + ('updated_at', models.DateTimeField()), + ('last_synced_at', models.DateTimeField(auto_now=True)), + ('default_branch', models.CharField(default='main', max_length=100)), + ('last_synced_hash', models.CharField(blank=True, max_length=40, null=True)), + ('dirty_at', models.DateTimeField(blank=True, null=True)), + ], ), ] diff --git a/apps/repositories/migrations/0002_initial.py b/apps/repositories/migrations/0002_initial.py new file mode 100644 index 0000000..b7ddfd9 --- /dev/null +++ b/apps/repositories/migrations/0002_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.27 on 2025-12-19 07:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('repositories', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='repository', + name='owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_repositories', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='contributor', + name='repository', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributors', to='repositories.repository'), + ), + migrations.AddField( + model_name='contributor', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='commit', + name='author', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='commits', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='commit', + name='repository', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commits', to='repositories.repository'), + ), + migrations.AlterUniqueTogether( + name='contributor', + unique_together={('repository', 'user')}, + ), + ] diff --git a/apps/repositories/migrations/0002_repository_default_branch_repository_dirty_at_and_more.py b/apps/repositories/migrations/0002_repository_default_branch_repository_dirty_at_and_more.py deleted file mode 100644 index 8b1ada1..0000000 --- a/apps/repositories/migrations/0002_repository_default_branch_repository_dirty_at_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.27 on 2025-12-17 11:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("repositories", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="repository", - name="default_branch", - field=models.CharField(default="main", max_length=100), - ), - migrations.AddField( - model_name="repository", - name="dirty_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="repository", - name="last_synced_hash", - field=models.CharField(blank=True, max_length=40, null=True), - ), - ] diff --git a/apps/repositories/serializers.py b/apps/repositories/serializers.py index 9616616..63f282b 100644 --- a/apps/repositories/serializers.py +++ b/apps/repositories/serializers.py @@ -1,58 +1,38 @@ -# apps/repositories/serializers.py from rest_framework import serializers -from .models import Repository, Contributor, Commit +from .models import Repository +from apps.users.serializers import UserSerializer + +class RepositoryListSerializer(serializers.ModelSerializer): + # owner 필드에 UserSerializer를 중첩하여 ID뿐만 아니라 이름, 아바타 등 전체 정보를 반환 + owner = UserSerializer(read_only=True) + + # 로그인한 유저의 개인 기여도 필드 추가 + my_commit_count = serializers.SerializerMethodField(help_text="현재 로그인한 사용자의 해당 레포지토리 커밋 수") -class RepositorySerializer(serializers.ModelSerializer): - """ - Serializer for the Repository model. - Converts Repository instances to JSON, including all fields. - 역참조 관계(contributors, commits)는 제외하여 순환 참조를 방지합니다. - """ class Meta: model = Repository fields = [ - 'id', - 'github_id', - 'name', - 'full_name', - 'description', - 'html_url', - 'stargazers_count', - 'language', - 'commit_count', - 'created_at', - 'updated_at', - 'last_synced_at', - 'owner', + 'id', + 'github_id', + 'name', + 'full_name', + 'description', + 'html_url', + 'stargazers_count', + 'language', + 'commit_count', # 레포지토리 전체 커밋 수 'default_branch', - 'last_synced_hash', - 'dirty_at', - ] - -class ContributorSerializer(serializers.ModelSerializer): - """ - Serializer for the Contributor model. - Includes related user information for read-only purposes. - """ - github_username = serializers.CharField(source='user.github_username', read_only=True) - avatar_url = serializers.URLField(source='user.avatar_url', read_only=True) - - class Meta: - model = Contributor - fields = [ - 'id', - 'user', - 'repository', - 'commit_count', - 'github_username', - 'avatar_url' + 'created_at', + 'updated_at', + 'owner', # 소유자 정보 (Object) + 'my_commit_count' # 내 기여도 (Integer) ] -class CommitSerializer(serializers.ModelSerializer): - """ - Serializer for the Commit model. - Converts Commit instances to JSON, including all fields. - """ - class Meta: - model = Commit - fields = '__all__' + def get_my_commit_count(self, obj): + user = self.context['request'].user + if not user.is_authenticated: + return 0 + + # View에서 prefetch_related('contributors')를 사용하므로 메모리 내에서 조회하여 DB 부하 없음 + contributor = next((c for c in obj.contributors.all() if c.user_id == user.id), None) + return contributor.commit_count if contributor else 0 \ No newline at end of file diff --git a/apps/repositories/urls.py b/apps/repositories/urls.py index afd2945..5602e61 100644 --- a/apps/repositories/urls.py +++ b/apps/repositories/urls.py @@ -1,7 +1,6 @@ from django.urls import path -from .views import RepositoryListView -app_name = 'repositories' +from .views import MyContributedRepositoryListView urlpatterns = [ - path("", RepositoryListView.as_view(), name="repository-list"), -] + path('', MyContributedRepositoryListView.as_view(), name='my-repository-list'), +] \ No newline at end of file diff --git a/apps/repositories/views.py b/apps/repositories/views.py index fce2f42..62f5f98 100644 --- a/apps/repositories/views.py +++ b/apps/repositories/views.py @@ -1,42 +1,28 @@ -from rest_framework.views import APIView +# apps/repositories/views.py +from rest_framework import generics from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response - +from .models import Repository +from .serializers import RepositoryListSerializer from drf_yasg.utils import swagger_auto_schema -from apps.repositories.models import Repository -from apps.repositories.serializers import RepositorySerializer - - -class RepositoryListView(APIView): +class MyContributedRepositoryListView(generics.ListAPIView): + serializer_class = RepositoryListSerializer permission_classes = [IsAuthenticated] + def get_queryset(self): + user = self.request.user + return Repository.objects.filter( + contributors__user=user, + contributors__commit_count__gt=0 + ).select_related('owner') \ + .prefetch_related('contributors') \ + .distinct() \ + .order_by('-updated_at') + @swagger_auto_schema( - operation_summary="내가 커밋한 모든 레포지토리 리스트", - operation_description=( - "Contributor 테이블에서 commit_count > 0 인 repo만 반환합니다.\n" - "즉, 내가 owner인지 상관없이 단 1커밋이라도 있는 모든 repo를 조회합니다." - ), - responses={200: RepositorySerializer(many=True)}, + operation_summary="참여 중인 레포지토리 목록 조회", + operation_description="사용자가 한 번이라도 커밋을 남긴 레포지토리 목록과 각 레포지토리별 내 커밋 수를 반환합니다.", + tags=["Repositories"] ) - def get(self, request): - try: - user = request.user - - # user가 contributor 이고 commit_count > 0 인 repo만 조회 - contributed_repos = Repository.objects.filter( - contributors__user=user, - contributors__commit_count__gt=0 - ).distinct().order_by("-updated_at") - - serializer = RepositorySerializer(contributed_repos, many=True) - return Response(serializer.data, status=200) - except Exception as e: - # 로깅은 Django의 기본 로깅 시스템을 사용 - import logging - logger = logging.getLogger(__name__) - logger.error(f"Error fetching repositories for user {request.user.id}: {str(e)}", exc_info=True) - return Response( - {"detail": "레포지토리 목록을 불러오는 중 오류가 발생했습니다."}, - status=500 - ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) \ No newline at end of file diff --git a/apps/shop/admin.py b/apps/shop/admin.py index b97a94f..23c7515 100644 --- a/apps/shop/admin.py +++ b/apps/shop/admin.py @@ -1,2 +1,25 @@ +from django.contrib import admin +from .models import UserCurrency, UserInventory, PointLog -# Register your models here. +@admin.register(UserCurrency) +class UserCurrencyAdmin(admin.ModelAdmin): + list_display = ('user', 'balance', 'total_earned', 'updated_at') + search_fields = ('user__username',) + readonly_fields = ('updated_at',) + +@admin.register(UserInventory) +class UserInventoryAdmin(admin.ModelAdmin): + list_display = ('user', 'item', 'quantity') + list_filter = ('item__item_type',) + search_fields = ('user__username', 'item__name') + +@admin.register(PointLog) +class PointLogAdmin(admin.ModelAdmin): + list_display = ('user', 'amount', 'reason', 'description', 'created_at') + list_filter = ('reason', 'created_at') + search_fields = ('user__username', 'description') + readonly_fields = ('user', 'amount', 'reason', 'description', 'created_at') + + # 로그는 관리자가 수정할 수 없도록 설정 (보안상 권장) + def has_add_permission(self, request): return False + def has_change_permission(self, request, obj=None): return False \ No newline at end of file diff --git a/apps/shop/migrations/0001_initial.py b/apps/shop/migrations/0001_initial.py index 9d5576d..2cdfed2 100644 --- a/apps/shop/migrations/0001_initial.py +++ b/apps/shop/migrations/0001_initial.py @@ -1,6 +1,5 @@ -# Generated by Django 4.2.27 on 2025-12-18 05:06 +# Generated by Django 4.2.27 on 2025-12-19 07:15 -from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -10,21 +9,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('items', '0003_item'), + ('items', '0001_initial'), ] operations = [ - migrations.CreateModel( - name='UserCurrency', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('balance', models.PositiveIntegerField(default=0)), - ('total_earned', models.PositiveIntegerField(default=0, help_text='누적 획득량(통계용)')), - ('updated_at', models.DateTimeField(auto_now=True)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='currency', to=settings.AUTH_USER_MODEL)), - ], - ), migrations.CreateModel( name='PointLog', fields=[ @@ -33,7 +21,15 @@ class Migration(migrations.Migration): ('reason', models.CharField(choices=[('COMMIT', 'Commit Reward'), ('BUY', 'Shop Purchase'), ('USE', 'Item Usage'), ('ADMIN', 'Admin Adjustment')], max_length=20)), ('description', models.CharField(blank=True, max_length=255)), ('created_at', models.DateTimeField(auto_now_add=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserCurrency', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('balance', models.PositiveIntegerField(default=0)), + ('total_earned', models.PositiveIntegerField(default=0, help_text='누적 획득량(통계용)')), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( @@ -42,10 +38,6 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('quantity', models.PositiveIntegerField(default=0)), ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='items.item')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to=settings.AUTH_USER_MODEL)), ], - options={ - 'unique_together': {('user', 'item')}, - }, ), ] diff --git a/apps/shop/migrations/0002_initial.py b/apps/shop/migrations/0002_initial.py new file mode 100644 index 0000000..100b1a7 --- /dev/null +++ b/apps/shop/migrations/0002_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.27 on 2025-12-19 07:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('shop', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='userinventory', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='usercurrency', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='currency', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='pointlog', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='userinventory', + unique_together={('user', 'item')}, + ), + ] diff --git a/apps/shop/tests.py b/apps/shop/tests.py deleted file mode 100644 index 4929020..0000000 --- a/apps/shop/tests.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Create your tests here. diff --git a/apps/shop/views.py b/apps/shop/views.py index 2c4bfea..7a793bb 100644 --- a/apps/shop/views.py +++ b/apps/shop/views.py @@ -1,3 +1,4 @@ +# apps/shop/views.py from django.db import transaction from django.shortcuts import get_object_or_404 from rest_framework import status, generics @@ -7,6 +8,8 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi import random +import logging +from django_q.tasks import async_task from .models import Item, UserCurrency, UserInventory, PointLog from .serializers import ( @@ -15,43 +18,38 @@ UserInventorySerializer, PurchaseRequestSerializer ) -from apps.aquatics.models import OwnBackground +from apps.aquatics.models import OwnBackground, ContributionFish from apps.items.models import FishSpecies -from apps.aquatics.models import ContributionFish + +logger = logging.getLogger(__name__) class ShopItemListView(generics.ListAPIView): - """ - 상점에서 판매 중인 아이템 목록을 조회합니다. - 로그인한 경우 'is_owned' 필드를 통해 배경 보유 여부를 알 수 있습니다. - """ permission_classes = [AllowAny] serializer_class = ShopItemSerializer + @swagger_auto_schema( + operation_summary="상점 아이템 목록 조회", + operation_description="판매 중인 모든 아이템(배경 해금권, 리롤권 등)을 조회합니다.", + tags=["Shop"] + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + def get_queryset(self): return Item.objects.filter(is_active=True).order_by('price') class MyShopInfoView(APIView): - """ - 내 재화(포인트) 잔액과 보유 중인 소모성 아이템(인벤토리)을 조회합니다. - """ permission_classes = [IsAuthenticated] @swagger_auto_schema( - operation_summary="내 지갑 및 인벤토리 조회", - responses={ - 200: openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'currency': openapi.Schema(type=openapi.TYPE_OBJECT, description="UserCurrencySerializer 구조"), - 'inventory': openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Schema(type=openapi.TYPE_OBJECT)) - } - ) - } + operation_summary="내 지갑 및 소모품 인벤토리 조회", + operation_description="보유 포인트 잔액과 리롤권 같은 소모성 아이템의 수량을 확인합니다.", + tags=["Shop"], + responses={200: "잔액 및 아이템 목록"} ) def get(self, request): currency, _ = UserCurrency.objects.get_or_create(user=request.user) inventory = UserInventory.objects.filter(user=request.user, quantity__gt=0) - return Response({ 'currency': UserCurrencySerializer(currency).data, 'inventory': UserInventorySerializer(inventory, many=True).data @@ -62,187 +60,72 @@ class PurchaseItemView(APIView): @swagger_auto_schema( operation_summary="아이템 구매", - operation_description=""" - 포인트를 사용하여 아이템을 구매합니다. - - 배경 해금권(BG_UNLOCK): 즉시 OwnBackground 생성 (재구매 불가) - - 소모품(REROLL 등): UserInventory 수량 증가 - """, + operation_description="포인트를 사용하여 배경 해금권이나 리롤권을 구매합니다.", + tags=["Shop"], request_body=PurchaseRequestSerializer, - responses={ - 200: "구매 성공", - 400: "잘못된 요청 (이미 보유중, 잔액 부족 등)", - 404: "아이템 없음" - } + responses={200: "구매 성공", 400: "잔액 부족 또는 이미 보유함"} ) def post(self, request): serializer = PurchaseRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) - item_id = serializer.validated_data['item_id'] - user = request.user - - # 1. 아이템 확인 - item = get_object_or_404(Item, id=item_id, is_active=True) - - try: - with transaction.atomic(): - # 2. 유저 지갑 잠금 (Row Lock) - 동시성 제어 - # 없는 경우 생성 (get_or_create는 atomic 블록 안에서 select_for_update와 함께 쓰기 까다로우므로 예외처리) - try: - currency = UserCurrency.objects.select_for_update().get(user=user) - except UserCurrency.DoesNotExist: - currency = UserCurrency.objects.create(user=user) - # 다시 잠금 획득 - currency = UserCurrency.objects.select_for_update().get(user=user) - - # 3. 잔액 확인 - if currency.balance < item.price: - return Response( - {"detail": "잔액이 부족합니다.", "current_balance": currency.balance}, - status=status.HTTP_400_BAD_REQUEST - ) - - # 4. 아이템 타입별 처리 - if item.item_type == Item.ItemType.BG_UNLOCK: - # 배경 해금권: 이미 가지고 있는지 확인 - if not item.target_background: - return Response({"detail": "설정 오류: 대상 배경이 없는 아이템입니다."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - if OwnBackground.objects.filter(user=user, background=item.target_background).exists(): - return Response({"detail": "이미 보유한 배경입니다."}, status=status.HTTP_400_BAD_REQUEST) - - # 배경 지급 - OwnBackground.objects.create(user=user, background=item.target_background) - purchase_desc = f"배경 구매: {item.target_background.name}" - - elif item.item_type == Item.ItemType.REROLL_TICKET: # ItemType Choice 확인 필요 (models.py에 REROLL로 정의됨) - # 소모품: 인벤토리 추가 - inventory, created = UserInventory.objects.get_or_create(user=user, item=item) - inventory.quantity += 1 - inventory.save() - purchase_desc = f"아이템 구매: {item.name}" - - else: - # 기타 아이템 (현재는 REROLL과 동일하게 처리하거나 에러) - inventory, created = UserInventory.objects.get_or_create(user=user, item=item) - inventory.quantity += 1 - inventory.save() - purchase_desc = f"아이템 구매: {item.name}" - - # 5. 비용 차감 및 저장 - currency.balance -= item.price - currency.save() - - # 6. 로그 기록 - PointLog.objects.create( - user=user, - amount=-item.price, - reason=PointLog.Reason.SHOP_PURCHASE, - description=purchase_desc - ) - - return Response({ - "detail": "구매가 완료되었습니다.", - "item": item.name, - "balance": currency.balance - }, status=status.HTTP_200_OK) - - except Exception as e: - # 예기치 못한 에러 - return Response({"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + item = get_object_or_404(Item, id=serializer.validated_data['item_id'], is_active=True) + currency_pre, _ = UserCurrency.objects.get_or_create(user=request.user) + with transaction.atomic(): + currency = UserCurrency.objects.select_for_update().get(id=currency_pre.id) + if currency.balance < item.price: + return Response({"detail": "잔액 부족"}, status=400) + if item.item_type == Item.ItemType.BG_UNLOCK: + if OwnBackground.objects.filter(user=request.user, background=item.target_background).exists(): + return Response({"detail": "이미 보유함"}, status=400) + OwnBackground.objects.create(user=request.user, background=item.target_background) + else: + inv, _ = UserInventory.objects.get_or_create(user=request.user, item=item) + inv.quantity += 1 + inv.save() + currency.balance -= item.price + currency.save() + PointLog.objects.create(user=request.user, amount=-item.price, reason=PointLog.Reason.SHOP_PURCHASE, description=f"구매: {item.name}") + return Response({"detail": "구매 완료", "balance": currency.balance}) class UseRerollTicketView(APIView): permission_classes = [IsAuthenticated] @swagger_auto_schema( - operation_summary="리롤권 사용 (물고기 변경)", - operation_description=""" - 소모품(REROLL_TICKET) 1개를 사용하여 특정 레포지토리의 내 물고기를 다시 뽑습니다. - - 현재 물고기와 동일한 성장 단계(Maturity)의 다른 물고기 중 하나로 랜덤 변경됩니다. - """, + operation_summary="리롤권 사용 (물고기 그룹 변경)", + operation_description="리롤권 1개를 사용하여 특정 레포지토리의 물고기 패밀리를 랜덤하게 교체합니다. 교체 후 SVG가 즉시 갱신됩니다.", + tags=["Shop"], request_body=openapi.Schema( type=openapi.TYPE_OBJECT, - properties={ - "repo_id": openapi.Schema(type=openapi.TYPE_INTEGER, description="리롤할 대상 레포지토리 ID") - }, + properties={"repo_id": openapi.Schema(type=openapi.TYPE_INTEGER)}, required=["repo_id"] ), - responses={ - 200: "리롤 성공 (변경된 물고기 정보 반환)", - 400: "아이템 부족 또는 잘못된 요청", - 404: "해당 레포지토리에 물고기가 없음" - } + responses={200: "리롤 성공", 400: "리롤권 부족"} ) def post(self, request): repo_id = request.data.get("repo_id") user = request.user - - if not repo_id: - return Response({"detail": "repo_id is required"}, status=status.HTTP_400_BAD_REQUEST) - try: with transaction.atomic(): - # 1. 인벤토리에서 리롤권 확인 및 차감 (Lock 사용) - # Item의 code가 'TICKET_REROLL' 이거나 item_type이 'REROLL'인 아이템을 찾습니다. - # 여기서는 item_type='REROLL' 인 아이템 중 하나를 쓴다고 가정 (보통 1종류) inventory_item = UserInventory.objects.select_for_update().filter( - user=user, - item__item_type=Item.ItemType.REROLL_TICKET, - quantity__gt=0 + user=user, item__item_type=Item.ItemType.REROLL_TICKET, quantity__gt=0 ).first() - if not inventory_item: - return Response({"detail": "리롤권이 부족합니다."}, status=status.HTTP_400_BAD_REQUEST) - - # 2. 대상 물고기 조회 - try: - target_fish = ContributionFish.objects.select_for_update().get( - contributor__user=user, - contributor__repository_id=repo_id - ) - except ContributionFish.DoesNotExist: - return Response({"detail": "해당 레포지토리에 보유한 물고기가 없습니다."}, status=status.HTTP_404_NOT_FOUND) - - # 3. 새로운 물고기 종 선정 로직 - current_species = target_fish.fish_species - current_maturity = current_species.maturity - - # 같은 maturity를 가진 모든 물고기 후보군 조회 - candidates = list(FishSpecies.objects.filter(maturity=current_maturity)) - - # 후보가 2개 이상이라면 현재 물고기는 제외하고 랜덤 선택 (다양성 보장) - if len(candidates) > 1: - candidates = [f for f in candidates if f.id != current_species.id] - - # 만약 후보가 없다면(유일한 종이라면) 그대로 유지될 수도 있음 (혹은 에러 처리) - if not candidates: - return Response({"detail": "교체 가능한 다른 물고기 종이 없습니다."}, status=status.HTTP_400_BAD_REQUEST) - - new_species = random.choice(candidates) - - # 4. 데이터 업데이트 - # 4-1. 물고기 종 변경 + return Response({"detail": "리롤권 부족"}, status=400) + target_fish = ContributionFish.objects.select_related('contributor').select_for_update().get( + contributor__user=user, contributor__repository_id=repo_id + ) + all_groups = list(FishSpecies.objects.values_list('group_code', flat=True).distinct()) + other_groups = [g for g in all_groups if g != target_fish.fish_species.group_code] + new_group = random.choice(other_groups) if other_groups else target_fish.fish_species.group_code + new_species = FishSpecies.objects.filter(group_code=new_group, required_commits__lte=target_fish.contributor.commit_count).order_by('-maturity').first() + if not new_species: + new_species = FishSpecies.objects.filter(group_code=new_group).order_by('maturity').first() target_fish.fish_species = new_species target_fish.save() - - # 4-2. 아이템 차감 inventory_item.quantity -= 1 inventory_item.save() - - # 4-3. 로그 기록 (선택 사항, PointLog는 재화용이므로 별도 로그가 없으면 생략 가능하나 PointLog를 Item Use 용도로도 쓴다면 기록) - PointLog.objects.create( - user=user, - amount=0, # 포인트 변동 없음 - reason=PointLog.Reason.ITEM_USE, # models.py에 ITEM_USE 추가 필요하거나 없으면 기타 처리 - description=f"리롤권 사용: {current_species.name} -> {new_species.name}" - ) - - return Response({ - "detail": "물고기가 변경되었습니다.", - "old_species": current_species.name, - "new_species": new_species.name, - "remaining_tickets": inventory_item.quantity - }, status=status.HTTP_200_OK) - + async_task('apps.aquatics.tasks.generate_aquarium_svg_task', user.id) + async_task('apps.aquatics.tasks.generate_fishtank_svg_task', repo_id, user.id) + return Response({"detail": "리롤 성공", "new_species": new_species.name}) except Exception as e: - return Response({"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file + return Response({"detail": "오류 발생"}, status=500) \ No newline at end of file diff --git a/apps/users/admin.py b/apps/users/admin.py index 3d5575a..29f6d0f 100644 --- a/apps/users/admin.py +++ b/apps/users/admin.py @@ -1,10 +1,20 @@ - from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import User @admin.register(User) class CustomUserAdmin(UserAdmin): - list_display = ('username', 'email', 'github_id', 'github_username', 'is_staff', 'is_active') - search_fields = ('username', 'email', 'github_username') - ordering = ('username',) + # 리스트 화면에 표시할 필드 + list_display = ('username', 'email', 'github_username', 'github_id', 'is_staff', 'date_joined') + # 필터링 옵션 + list_filter = ('is_staff', 'is_superuser', 'is_active', 'groups') + # 검색 기능 + search_fields = ('username', 'email', 'github_username', 'github_id') + # 상세 페이지 필드 구성 + fieldsets = UserAdmin.fieldsets + ( + ('GitHub Information', {'fields': ('github_id', 'github_username', 'avatar_url')}), + ) + add_fieldsets = UserAdmin.add_fieldsets + ( + ('GitHub Information', {'fields': ('github_id', 'github_username', 'avatar_url')}), + ) + ordering = ('-date_joined',) \ No newline at end of file diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py index 0170265..f21ddd9 100644 --- a/apps/users/migrations/0001_initial.py +++ b/apps/users/migrations/0001_initial.py @@ -1,9 +1,9 @@ -# Generated by Django 5.2.6 on 2025-11-10 12:44 +# Generated by Django 4.2.27 on 2025-12-19 07:15 import django.contrib.auth.models import django.contrib.auth.validators -import django.utils.timezone from django.db import migrations, models +import django.utils.timezone class Migration(migrations.Migration): diff --git a/apps/users/tasks.py b/apps/users/tasks.py index 70036e2..493d86f 100644 --- a/apps/users/tasks.py +++ b/apps/users/tasks.py @@ -3,9 +3,11 @@ from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone +from django_q.tasks import async_task from github import Github, GithubException from apps.repositories.models import Repository, Contributor, Commit from apps.shop.models import UserCurrency, PointLog +from apps.aquatics.logic import update_or_create_contribution_fish COMMIT_REWARD_PER_POINT = 10 # 1 커밋당 지급할 포인트 @@ -160,19 +162,23 @@ def _sync_contributors(repository_model: Repository, repo_obj): if diff > 0: reward_amount = diff * COMMIT_REWARD_PER_POINT - # 재화 지급 - currency, _ = UserCurrency.objects.get_or_create(user=user_obj) - currency.balance += reward_amount - currency.total_earned += reward_amount - currency.save() - - # 로그 기록 - PointLog.objects.create( - user=user_obj, - amount=reward_amount, - reason=PointLog.Reason.COMMIT_REWARD, - description=f"{repository_model.name}: +{diff} commits" - ) + # [동시성 제어 적용] 지갑 레코드 확보 (없으면 생성) + UserCurrency.objects.get_or_create(user=user_obj) + + with transaction.atomic(): + # Lock을 걸고 데이터를 가져와서 업데이트 + currency = UserCurrency.objects.select_for_update().get(user=user_obj) + currency.balance += reward_amount + currency.total_earned += reward_amount + currency.save() + + # 로그 기록 (원자성을 위해 같은 트랜잭션 내에 위치) + PointLog.objects.create( + user=user_obj, + amount=reward_amount, + reason=PointLog.Reason.COMMIT_REWARD, + description=f"{repository_model.name}: +{diff} commits" + ) logger.info(f"Rewarded {user_obj.username} {reward_amount} points for {diff} new commits in {repository_model.name}.") @@ -180,6 +186,16 @@ def _sync_contributors(repository_model: Repository, repo_obj): if contributor.commit_count != new_count: contributor.commit_count = new_count contributor.save(update_fields=['commit_count']) + + # 물고기 진화/할당 로직 호출 + contributor_model = Contributor.objects.get(repository=repository_model, user=user_obj) + update_or_create_contribution_fish(contributor_model) + + # 개인 아쿠아리움 SVG 갱신 예약 + async_task('apps.aquatics.tasks.generate_aquarium_svg_task', user_obj.id) + + # 해당 레포지토리 공용 수족관 SVG 갱신 예약 + async_task('apps.aquatics.tasks.generate_fishtank_svg_task', repository_model.id) def _sync_commits(repository_model: Repository, repo_obj): diff --git a/apps/users/urls.py b/apps/users/urls.py deleted file mode 100644 index f031e5c..0000000 --- a/apps/users/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -# apps/users/urls.py - -app_name = 'users' - -urlpatterns = [ - # Add user-specific URLs here in the future -] diff --git a/assign_fish_species.py b/assign_fish_species.py deleted file mode 100644 index f6f10b5..0000000 --- a/assign_fish_species.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python -""" -Django shell에서 사용할 FishSpecies 할당 스크립트 - -사용법: -1. Django shell 실행: uv run manage.py shell -2. 이 스크립트 내용을 복사해서 실행하거나 -3. exec(open('assign_fish_species.py').read()) 실행 - -예시: - assign_fish_to_user('your_github_username', 'repository_name', 'FishSpecies_name') - 또는 - assign_fish_to_user('your_github_username', 'repository_name', fish_species_id=1) -""" - -from apps.users.models import User -from apps.repositories.models import Repository, Contributor -from apps.items.models import FishSpecies -from apps.aquatics.models import ContributionFish - - -def assign_fish_to_user(github_username, repository_name, fish_species_name=None, fish_species_id=None): - """ - GitHub 아이디로 사용자에게 FishSpecies를 할당합니다. - - Args: - github_username: GitHub 사용자명 (username 또는 github_username) - repository_name: 레포지토리 이름 (name 또는 full_name) - fish_species_name: FishSpecies 이름 (name 필드) - fish_species_id: FishSpecies ID (fish_species_name 대신 사용 가능) - - Returns: - 생성되거나 업데이트된 ContributionFish 객체 - """ - # 1. User 찾기 - try: - user = User.objects.get(github_username=github_username) - except User.DoesNotExist: - try: - user = User.objects.get(username=github_username) - except User.DoesNotExist: - print(f"❌ 사용자를 찾을 수 없습니다: {github_username}") - print("사용 가능한 사용자 목록:") - for u in User.objects.all()[:10]: - print(f" - {u.username} (github: {u.github_username})") - return None - - print(f"✅ 사용자 찾음: {user.username} (GitHub: {user.github_username})") - - # 2. Repository 찾기 - try: - repo = Repository.objects.get(name=repository_name) - except Repository.DoesNotExist: - try: - repo = Repository.objects.get(full_name=repository_name) - except Repository.DoesNotExist: - print(f"❌ 레포지토리를 찾을 수 없습니다: {repository_name}") - print("사용 가능한 레포지토리 목록:") - for r in Repository.objects.all()[:10]: - print(f" - {r.name} (full_name: {r.full_name})") - return None - - print(f"✅ 레포지토리 찾음: {repo.name} ({repo.full_name})") - - # 3. Contributor 찾기 또는 생성 - contributor, created = Contributor.objects.get_or_create( - user=user, - repository=repo, - defaults={'commit_count': 0} # 기본값 - ) - - if created: - print(f"✅ Contributor 생성됨: {user.username} in {repo.name}") - else: - print(f"✅ Contributor 찾음: {user.username} in {repo.name} (commits: {contributor.commit_count})") - - # 4. FishSpecies 찾기 - if fish_species_id: - try: - fish_species = FishSpecies.objects.get(id=fish_species_id) - except FishSpecies.DoesNotExist: - print(f"❌ FishSpecies를 찾을 수 없습니다 (ID: {fish_species_id})") - return None - elif fish_species_name: - try: - fish_species = FishSpecies.objects.get(name=fish_species_name) - except FishSpecies.DoesNotExist: - print(f"❌ FishSpecies를 찾을 수 없습니다: {fish_species_name}") - print("사용 가능한 FishSpecies 목록:") - for fs in FishSpecies.objects.all()[:20]: - print(f" - ID: {fs.id}, Name: {fs.name}, Maturity: {fs.get_maturity_display()}, Required: {fs.required_commits}") - return None - else: - print("❌ fish_species_name 또는 fish_species_id를 제공해주세요.") - return None - - print(f"✅ FishSpecies 찾음: {fish_species.name} (Maturity: {fish_species.get_maturity_display()}, Required: {fish_species.required_commits} commits)") - - # 5. ContributionFish 생성 또는 업데이트 - contribution_fish, created = ContributionFish.objects.get_or_create( - contributor=contributor, - defaults={ - 'fish_species': fish_species, - 'is_visible_in_fishtank': True, - 'is_visible_in_aquarium': True, - } - ) - - if not created: - # 이미 존재하면 업데이트 - contribution_fish.fish_species = fish_species - contribution_fish.save() - print(f"✅ ContributionFish 업데이트됨: {contribution_fish}") - else: - print(f"✅ ContributionFish 생성됨: {contribution_fish}") - - return contribution_fish - - -def list_available_fish_species(): - """사용 가능한 모든 FishSpecies 목록을 출력합니다.""" - print("\n=== 사용 가능한 FishSpecies 목록 ===") - for fs in FishSpecies.objects.all().order_by('group_code', 'maturity'): - print(f"ID: {fs.id:3d} | {fs.name:30s} | {fs.get_maturity_display():12s} | Required: {fs.required_commits:3d} commits | Group: {fs.group_code}") - - -def list_user_contributions(github_username): - """특정 사용자의 모든 ContributionFish를 출력합니다.""" - try: - user = User.objects.get(github_username=github_username) - except User.DoesNotExist: - try: - user = User.objects.get(username=github_username) - except User.DoesNotExist: - print(f"❌ 사용자를 찾을 수 없습니다: {github_username}") - return - - print(f"\n=== {user.username}의 ContributionFish 목록 ===") - contributors = Contributor.objects.filter(user=user) - for contrib in contributors: - try: - cf = contrib.contribution_fish - print(f"Repository: {contrib.repository.name}") - print(f" Fish: {cf.fish_species.name} ({cf.fish_species.get_maturity_display()})") - print(f" Visible in Fishtank: {cf.is_visible_in_fishtank}") - print(f" Visible in Aquarium: {cf.is_visible_in_aquarium}") - print() - except ContributionFish.DoesNotExist: - print(f"Repository: {contrib.repository.name} - Fish 없음") - print() - - -# 사용 예시 출력 -print(""" -=== FishSpecies 할당 스크립트 === - -사용법: -1. assign_fish_to_user('github_username', 'repository_name', 'FishSpecies_name') - 예: assign_fish_to_user('junha', 'GithubAquarium_Back', 'Salmon') - -2. assign_fish_to_user('github_username', 'repository_name', fish_species_id=1) - 예: assign_fish_to_user('junha', 'GithubAquarium_Back', fish_species_id=5) - -3. list_available_fish_species() - 사용 가능한 모든 FishSpecies 목록 보기 - -4. list_user_contributions('github_username') - 특정 사용자의 할당된 물고기 목록 보기 - -""") - - - - diff --git a/erd.png b/erd.png index 11509e4..e69de29 100644 Binary files a/erd.png and b/erd.png differ diff --git a/media_files/backgrounds/bg-deep-1.png b/fixtures/backgrounds/bg-deep-1.png similarity index 100% rename from media_files/backgrounds/bg-deep-1.png rename to fixtures/backgrounds/bg-deep-1.png diff --git a/media_files/backgrounds/bg-deep-2.png b/fixtures/backgrounds/bg-deep-2.png similarity index 100% rename from media_files/backgrounds/bg-deep-2.png rename to fixtures/backgrounds/bg-deep-2.png diff --git a/media_files/backgrounds/bg-ocean.png b/fixtures/backgrounds/bg-ocean.png similarity index 100% rename from media_files/backgrounds/bg-ocean.png rename to fixtures/backgrounds/bg-ocean.png diff --git a/fixtures/templates/CPFishbun_1.svg b/fixtures/templates/CPFishbun_1.svg new file mode 100644 index 0000000..e741aef --- /dev/null +++ b/fixtures/templates/CPFishbun_1.svg @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/CPFishbun_2.svg b/fixtures/templates/CPFishbun_2.svg new file mode 100644 index 0000000..3aa93c1 --- /dev/null +++ b/fixtures/templates/CPFishbun_2.svg @@ -0,0 +1,438 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/CPFishbun_3.svg b/fixtures/templates/CPFishbun_3.svg new file mode 100644 index 0000000..70d2904 --- /dev/null +++ b/fixtures/templates/CPFishbun_3.svg @@ -0,0 +1,640 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/RBFishbun_1.svg b/fixtures/templates/RBFishbun_1.svg new file mode 100644 index 0000000..09ca887 --- /dev/null +++ b/fixtures/templates/RBFishbun_1.svg @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/RBFishbun_2.svg b/fixtures/templates/RBFishbun_2.svg new file mode 100644 index 0000000..95b684e --- /dev/null +++ b/fixtures/templates/RBFishbun_2.svg @@ -0,0 +1,438 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/RBFishbun_3.svg b/fixtures/templates/RBFishbun_3.svg new file mode 100644 index 0000000..cb882cf --- /dev/null +++ b/fixtures/templates/RBFishbun_3.svg @@ -0,0 +1,591 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SPFishbun_1.svg b/fixtures/templates/SPFishbun_1.svg new file mode 100644 index 0000000..e131bc3 --- /dev/null +++ b/fixtures/templates/SPFishbun_1.svg @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SPFishbun_2.svg b/fixtures/templates/SPFishbun_2.svg new file mode 100644 index 0000000..d803665 --- /dev/null +++ b/fixtures/templates/SPFishbun_2.svg @@ -0,0 +1,438 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SPFishbun_3.svg b/fixtures/templates/SPFishbun_3.svg new file mode 100644 index 0000000..8f7d8ce --- /dev/null +++ b/fixtures/templates/SPFishbun_3.svg @@ -0,0 +1,592 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/ShrimpWich_1.svg b/fixtures/templates/ShrimpWich_1.svg new file mode 100644 index 0000000..e60f98c --- /dev/null +++ b/fixtures/templates/ShrimpWich_1.svg @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/ShrimpWich_2.svg b/fixtures/templates/ShrimpWich_2.svg new file mode 100644 index 0000000..24b3792 --- /dev/null +++ b/fixtures/templates/ShrimpWich_2.svg @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/ShrimpWich_3.svg b/fixtures/templates/ShrimpWich_3.svg new file mode 100644 index 0000000..2e0bd6a --- /dev/null +++ b/fixtures/templates/ShrimpWich_3.svg @@ -0,0 +1,588 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/ShrimpWich_4.svg b/fixtures/templates/ShrimpWich_4.svg new file mode 100644 index 0000000..4367e24 --- /dev/null +++ b/fixtures/templates/ShrimpWich_4.svg @@ -0,0 +1,723 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/ShrimpWich_5.svg b/fixtures/templates/ShrimpWich_5.svg new file mode 100644 index 0000000..a98fbf0 --- /dev/null +++ b/fixtures/templates/ShrimpWich_5.svg @@ -0,0 +1,870 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/ShrimpWich_6.svg b/fixtures/templates/ShrimpWich_6.svg new file mode 100644 index 0000000..40f24ec --- /dev/null +++ b/fixtures/templates/ShrimpWich_6.svg @@ -0,0 +1,1437 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SpaceOcto_1.svg b/fixtures/templates/SpaceOcto_1.svg new file mode 100644 index 0000000..9ce9207 --- /dev/null +++ b/fixtures/templates/SpaceOcto_1.svg @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SpaceOcto_2.svg b/fixtures/templates/SpaceOcto_2.svg new file mode 100644 index 0000000..edab6d1 --- /dev/null +++ b/fixtures/templates/SpaceOcto_2.svg @@ -0,0 +1,428 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SpaceOcto_3.svg b/fixtures/templates/SpaceOcto_3.svg new file mode 100644 index 0000000..fac1c95 --- /dev/null +++ b/fixtures/templates/SpaceOcto_3.svg @@ -0,0 +1,469 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SpaceOcto_4.svg b/fixtures/templates/SpaceOcto_4.svg new file mode 100644 index 0000000..dc33ccc --- /dev/null +++ b/fixtures/templates/SpaceOcto_4.svg @@ -0,0 +1,650 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SpaceOcto_5.svg b/fixtures/templates/SpaceOcto_5.svg new file mode 100644 index 0000000..6a65075 --- /dev/null +++ b/fixtures/templates/SpaceOcto_5.svg @@ -0,0 +1,830 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/templates/SpaceOcto_6.svg b/fixtures/templates/SpaceOcto_6.svg new file mode 100644 index 0000000..1affc71 --- /dev/null +++ b/fixtures/templates/SpaceOcto_6.svg @@ -0,0 +1,961 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/media_files/backgrounds/bg-deep-1_kpANQtK.png b/media_files/backgrounds/bg-deep-1_kpANQtK.png deleted file mode 100644 index f1280c7..0000000 Binary files a/media_files/backgrounds/bg-deep-1_kpANQtK.png and /dev/null differ diff --git a/media_files/backgrounds/bg-deep-1_ye2JQDd.png b/media_files/backgrounds/bg-deep-1_ye2JQDd.png deleted file mode 100644 index f1280c7..0000000 Binary files a/media_files/backgrounds/bg-deep-1_ye2JQDd.png and /dev/null differ diff --git a/media_files/backgrounds/bg-deep-2_Qtqn4Gl.png b/media_files/backgrounds/bg-deep-2_Qtqn4Gl.png deleted file mode 100644 index 718c6f3..0000000 Binary files a/media_files/backgrounds/bg-deep-2_Qtqn4Gl.png and /dev/null differ diff --git a/media_files/backgrounds/bg-ocean_lobctBF.png b/media_files/backgrounds/bg-ocean_lobctBF.png deleted file mode 100644 index eb36089..0000000 Binary files a/media_files/backgrounds/bg-ocean_lobctBF.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 349363f..4c28093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "githubaquarium-back" version = "0.1.0" description = "Add your description here" readme = "README.md" -requires-python = ">=3.12" +requires-python = "==3.12.*" dependencies = [ "dj-rest-auth[with-social]>=6.0.0", "django-allauth[socialaccount]<0.61.0", diff --git a/readme.md b/readme.md index 88d5762..1bf5c7d 100644 --- a/readme.md +++ b/readme.md @@ -1,15 +1,21 @@ # uv 설치 curl -LsSf https://astral.sh/uv/install.sh | sh -# 도움 되는 명령어 -uv run ruff check . --fix +# 필요한 명령어 +uv run manage.py collectstatic +uv run manage.py migrate +sqlite3 db.sqlite3 "PRAGMA journal_mode=WAL;" # sudo apt install sqlite3 필요 uv run manage.py graph_models -a -o erd.png uv run manage.py show_urls -uv run manage.py migrate -uv run manage.py collectstatic -git ls-files '*.py' | xargs -I {} sh -c 'echo "\n=== {} ===" && cat {}' > all.txt +uv run ruff check . --fix +tree -L 4 -I ".venv|__pycache__|.ruff_cache|staticfiles_collected|logs" > structure.txt +git ls-files '*.py' | xargs -I {} sh -c 'echo "\n=== {} ===" && cat {}' > allcode.txt + +uv run python manage.py init_items # 커스텀 +uv run python manage.py createsuperuser # 관리자 페이지용 +uv run ./manage.py qcluster # worker 로컬 작동 -.env 예시 format +# .env 예시 format DEBUG='' SECRET_KEY='' diff --git a/uv.lock b/uv.lock index e2871e1..ea75d5d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,6 @@ version = 1 -revision = 2 -requires-python = ">=3.12" -resolution-markers = [ - "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", - "python_full_version < '3.14' and platform_python_implementation != 'PyPy'", - "platform_python_implementation == 'PyPy'", -] +revision = 3 +requires-python = "==3.12.*" [[package]] name = "ansicon" @@ -65,7 +60,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'" }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -81,40 +76,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -134,28 +95,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] @@ -204,21 +143,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/c4/0da6e55595d9b9cd3b6eb5dc22f3a07ded7f116a3ea72629cab595abb804/cryptography-46.0.1-cp311-abi3-win32.whl", hash = "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0", size = 3058327, upload-time = "2025-09-17T00:09:03.726Z" }, { url = "https://files.pythonhosted.org/packages/95/0f/cd29a35e0d6e78a0ee61793564c8cff0929c38391cb0de27627bdc7525aa/cryptography-46.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7", size = 3523893, upload-time = "2025-09-17T00:09:06.272Z" }, { url = "https://files.pythonhosted.org/packages/f2/dd/eea390f3e78432bc3d2f53952375f8b37cb4d37783e626faa6a51e751719/cryptography-46.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0", size = 2932145, upload-time = "2025-09-17T00:09:08.568Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fb/c73588561afcd5e24b089952bd210b14676c0c5bf1213376350ae111945c/cryptography-46.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:4c49eda9a23019e11d32a0eb51a27b3e7ddedde91e099c0ac6373e3aacc0d2ee", size = 7193928, upload-time = "2025-09-17T00:09:10.595Z" }, - { url = "https://files.pythonhosted.org/packages/26/34/0ff0bb2d2c79f25a2a63109f3b76b9108a906dd2a2eb5c1d460b9938adbb/cryptography-46.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9babb7818fdd71394e576cf26c5452df77a355eac1a27ddfa24096665a27f8fd", size = 4293515, upload-time = "2025-09-17T00:09:12.861Z" }, - { url = "https://files.pythonhosted.org/packages/df/b7/d4f848aee24ecd1be01db6c42c4a270069a4f02a105d9c57e143daf6cf0f/cryptography-46.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9f2c4cc63be3ef43c0221861177cee5d14b505cd4d4599a89e2cd273c4d3542a", size = 4545619, upload-time = "2025-09-17T00:09:15.397Z" }, - { url = "https://files.pythonhosted.org/packages/44/a5/42fedefc754fd1901e2d95a69815ea4ec8a9eed31f4c4361fcab80288661/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:41c281a74df173876da1dc9a9b6953d387f06e3d3ed9284e3baae3ab3f40883a", size = 4299160, upload-time = "2025-09-17T00:09:17.155Z" }, - { url = "https://files.pythonhosted.org/packages/86/a1/cd21174f56e769c831fbbd6399a1b7519b0ff6280acec1b826d7b072640c/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0a17377fa52563d730248ba1f68185461fff36e8bc75d8787a7dd2e20a802b7a", size = 3994491, upload-time = "2025-09-17T00:09:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/a8cbfa1c029987ddc746fd966711d4fa71efc891d37fbe9f030fe5ab4eec/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0d1922d9280e08cde90b518a10cd66831f632960a8d08cb3418922d83fce6f12", size = 4960157, upload-time = "2025-09-17T00:09:20.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/ae/63a84e6789e0d5a2502edf06b552bcb0fa9ff16147265d5c44a211942abe/cryptography-46.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:af84e8e99f1a82cea149e253014ea9dc89f75b82c87bb6c7242203186f465129", size = 4577263, upload-time = "2025-09-17T00:09:23.356Z" }, - { url = "https://files.pythonhosted.org/packages/ef/8f/1b9fa8e92bd9cbcb3b7e1e593a5232f2c1e6f9bd72b919c1a6b37d315f92/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ef648d2c690703501714588b2ba640facd50fd16548133b11b2859e8655a69da", size = 4298703, upload-time = "2025-09-17T00:09:25.566Z" }, - { url = "https://files.pythonhosted.org/packages/c3/af/bb95db070e73fea3fae31d8a69ac1463d89d1c084220f549b00dd01094a8/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:e94eb5fa32a8a9f9bf991f424f002913e3dd7c699ef552db9b14ba6a76a6313b", size = 4926363, upload-time = "2025-09-17T00:09:27.451Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3b/d8fb17ffeb3a83157a1cc0aa5c60691d062aceecba09c2e5e77ebfc1870c/cryptography-46.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:534b96c0831855e29fc3b069b085fd185aa5353033631a585d5cd4dd5d40d657", size = 4576958, upload-time = "2025-09-17T00:09:29.924Z" }, - { url = "https://files.pythonhosted.org/packages/d9/46/86bc3a05c10c8aa88c8ae7e953a8b4e407c57823ed201dbcba55c4d655f4/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9b55038b5c6c47559aa33626d8ecd092f354e23de3c6975e4bb205df128a2a0", size = 4422507, upload-time = "2025-09-17T00:09:32.222Z" }, - { url = "https://files.pythonhosted.org/packages/a8/4e/387e5a21dfd2b4198e74968a541cfd6128f66f8ec94ed971776e15091ac3/cryptography-46.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ec13b7105117dbc9afd023300fb9954d72ca855c274fe563e72428ece10191c0", size = 4683964, upload-time = "2025-09-17T00:09:34.118Z" }, - { url = "https://files.pythonhosted.org/packages/25/a3/f9f5907b166adb8f26762071474b38bbfcf89858a5282f032899075a38a1/cryptography-46.0.1-cp314-cp314t-win32.whl", hash = "sha256:504e464944f2c003a0785b81668fe23c06f3b037e9cb9f68a7c672246319f277", size = 3029705, upload-time = "2025-09-17T00:09:36.381Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/4d3a4f1850db2e71c2b1628d14b70b5e4c1684a1bd462f7fffb93c041c38/cryptography-46.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c52fded6383f7e20eaf70a60aeddd796b3677c3ad2922c801be330db62778e05", size = 3502175, upload-time = "2025-09-17T00:09:38.261Z" }, - { url = "https://files.pythonhosted.org/packages/52/c7/9f10ad91435ef7d0d99a0b93c4360bea3df18050ff5b9038c489c31ac2f5/cryptography-46.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9495d78f52c804b5ec8878b5b8c7873aa8e63db9cd9ee387ff2db3fffe4df784", size = 2912354, upload-time = "2025-09-17T00:09:40.078Z" }, { url = "https://files.pythonhosted.org/packages/98/e5/fbd632385542a3311915976f88e0dfcf09e62a3fc0aff86fb6762162a24d/cryptography-46.0.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b", size = 7255677, upload-time = "2025-09-17T00:09:42.407Z" }, { url = "https://files.pythonhosted.org/packages/56/3e/13ce6eab9ad6eba1b15a7bd476f005a4c1b3f299f4c2f32b22408b0edccf/cryptography-46.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8", size = 4301110, upload-time = "2025-09-17T00:09:45.614Z" }, { url = "https://files.pythonhosted.org/packages/a2/67/65dc233c1ddd688073cf7b136b06ff4b84bf517ba5529607c9d79720fc67/cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead", size = 4562369, upload-time = "2025-09-17T00:09:47.601Z" }, @@ -536,56 +460,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, ] [[package]] @@ -645,18 +519,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/06/c6/a3124dee667a423f2c637cfd262a54d67d8ccf3e160f3c50f622a85b7723/pynacl-1.6.0.tar.gz", hash = "sha256:cb36deafe6e2bce3b286e5d1f3e1c246e0ccdb8808ddb4550bb2792f2df298f2", size = 3505641, upload-time = "2025-09-10T23:39:22.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/24/1b639176401255605ba7c2b93a7b1eb1e379e0710eca62613633eb204201/pynacl-1.6.0-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:f46386c24a65383a9081d68e9c2de909b1834ec74ff3013271f1bca9c2d233eb", size = 384141, upload-time = "2025-09-10T23:38:28.675Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7b/874efdf57d6bf172db0df111b479a553c3d9e8bb4f1f69eb3ffff772d6e8/pynacl-1.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dea103a1afcbc333bc0e992e64233d360d393d1e63d0bc88554f572365664348", size = 808132, upload-time = "2025-09-10T23:38:38.995Z" }, - { url = "https://files.pythonhosted.org/packages/f3/61/9b53f5913f3b75ac3d53170cdb897101b2b98afc76f4d9d3c8de5aa3ac05/pynacl-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:04f20784083014e265ad58c1b2dd562c3e35864b5394a14ab54f5d150ee9e53e", size = 1407253, upload-time = "2025-09-10T23:38:40.492Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0a/b138916b22bbf03a1bdbafecec37d714e7489dd7bcaf80cd17852f8b67be/pynacl-1.6.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbcc4452a1eb10cd5217318c822fde4be279c9de8567f78bad24c773c21254f8", size = 843719, upload-time = "2025-09-10T23:38:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/01/3b/17c368197dfb2c817ce033f94605a47d0cc27901542109e640cef263f0af/pynacl-1.6.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fed9fe1bec9e7ff9af31cd0abba179d0e984a2960c77e8e5292c7e9b7f7b5d", size = 1445441, upload-time = "2025-09-10T23:38:33.078Z" }, - { url = "https://files.pythonhosted.org/packages/35/3c/f79b185365ab9be80cd3cd01dacf30bf5895f9b7b001e683b369e0bb6d3d/pynacl-1.6.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:10d755cf2a455d8c0f8c767a43d68f24d163b8fe93ccfaabfa7bafd26be58d73", size = 825691, upload-time = "2025-09-10T23:38:34.832Z" }, - { url = "https://files.pythonhosted.org/packages/f7/1f/8b37d25e95b8f2a434a19499a601d4d272b9839ab8c32f6b0fc1e40c383f/pynacl-1.6.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:536703b8f90e911294831a7fbcd0c062b837f3ccaa923d92a6254e11178aaf42", size = 1410726, upload-time = "2025-09-10T23:38:36.893Z" }, - { url = "https://files.pythonhosted.org/packages/bd/93/5a4a4cf9913014f83d615ad6a2df9187330f764f606246b3a744c0788c03/pynacl-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b08eab48c9669d515a344fb0ef27e2cbde847721e34bba94a343baa0f33f1f4", size = 801035, upload-time = "2025-09-10T23:38:42.109Z" }, - { url = "https://files.pythonhosted.org/packages/bf/60/40da6b0fe6a4d5fd88f608389eb1df06492ba2edca93fca0b3bebff9b948/pynacl-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5789f016e08e5606803161ba24de01b5a345d24590a80323379fc4408832d290", size = 1371854, upload-time = "2025-09-10T23:38:44.16Z" }, - { url = "https://files.pythonhosted.org/packages/44/b2/37ac1d65008f824cba6b5bf68d18b76d97d0f62d7a032367ea69d4a187c8/pynacl-1.6.0-cp314-cp314t-win32.whl", hash = "sha256:4853c154dc16ea12f8f3ee4b7e763331876316cc3a9f06aeedf39bcdca8f9995", size = 230345, upload-time = "2025-09-10T23:38:48.276Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5a/9234b7b45af890d02ebee9aae41859b9b5f15fb4a5a56d88e3b4d1659834/pynacl-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:347dcddce0b4d83ed3f32fd00379c83c425abee5a9d2cd0a2c84871334eaff64", size = 243103, upload-time = "2025-09-10T23:38:45.503Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2c/c1a0f19d720ab0af3bc4241af2bdf4d813c3ecdcb96392b5e1ddf2d8f24f/pynacl-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2d6cd56ce4998cb66a6c112fda7b1fdce5266c9f05044fa72972613bef376d15", size = 187778, upload-time = "2025-09-10T23:38:46.731Z" }, { url = "https://files.pythonhosted.org/packages/63/37/87c72df19857c5b3b47ace6f211a26eb862ada495cc96daa372d96048fca/pynacl-1.6.0-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:f4b3824920e206b4f52abd7de621ea7a44fd3cb5c8daceb7c3612345dfc54f2e", size = 382610, upload-time = "2025-09-10T23:38:49.459Z" }, { url = "https://files.pythonhosted.org/packages/0c/64/3ce958a5817fd3cc6df4ec14441c43fd9854405668d73babccf77f9597a3/pynacl-1.6.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:16dd347cdc8ae0b0f6187a2608c0af1c8b7ecbbe6b4a06bff8253c192f696990", size = 798744, upload-time = "2025-09-10T23:38:58.531Z" }, { url = "https://files.pythonhosted.org/packages/e4/8a/3f0dd297a0a33fa3739c255feebd0206bb1df0b44c52fbe2caf8e8bc4425/pynacl-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16c60daceee88d04f8d41d0a4004a7ed8d9a5126b997efd2933e08e93a3bd850", size = 1397879, upload-time = "2025-09-10T23:39:00.44Z" }, @@ -731,34 +593,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]]