diff --git a/config/settings.py b/config/settings.py index 2127470..799973e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -66,6 +66,7 @@ INSTALLED_APPS = [ 'codes', 'notifications', 'nocodetools', + 'homes', ] MIDDLEWARE = [ @@ -117,6 +118,22 @@ DATABASES = { } } +# cache +REDIS_PASSWORD = env("REDIS_PASSWORD") + +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': f'redis://:{REDIS_PASSWORD}@redis:6379/1', + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'SERIALIZER': 'django_redis.serializers.json.JSONSerializer', + }, + "KEY_PREFIX": "colio", + "TIMEOUT": 60 * 60, + } +} + # aws s3 AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID') AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY') @@ -250,6 +267,9 @@ SESSION_COOKIE_SECURE = True GOOGLE_CLIENT_ID = env('GOOGLE_CLIENT_ID') +GITHUB_CLIENT_ID = env('GITHUB_CLIENT_ID') +GITHUB_CLIENT_SECRET = env('GITHUB_CLIENT_SECRET') + ##CORS # CSRF_TRUSTED_ORIGINS = [] # CORS_ALLOWED_ORIGINS = ["http://localhost:3000", "https://{프론트주소}.vercel.app/"] diff --git a/users/models.py b/users/models.py index fa9b869..e077e52 100644 --- a/users/models.py +++ b/users/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.conf import settings from common.models.baseModels import BaseModel from common.models.choiceModels import GenderChoices, CertificateCodeUseType from common.utils.codeManger import set_expire @@ -9,6 +10,9 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, Permis from common.models.choiceModels import NotificationType +from django.utils import timezone +from datetime import timedelta + class UserManager(BaseUserManager): def create_user(self, email, password, **kwargs): user = self.model(email = email, **kwargs) @@ -72,3 +76,14 @@ class User(BaseModel, AbstractBaseUser, PermissionsMixin): if self.banner_image: self.banner_image.delete(save=False) super().delete(*args, **kwargs) + +class GithubToken(models.Model): + user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name='github_token', on_delete=models.CASCADE) + access_token = models.CharField(max_length=255, blank=True, null=True) + refresh_token = models.CharField(max_length=255, blank=True, null=True) + scope = models.CharField(max_length=255, blank=True) + token_type = models.CharField(max_length=32, default='bearer') + expires_at = models.DateTimeField(blank=True, null=True) + + def is_expired(self) -> bool: + return self.expires_at and timezone.now() >= self.expires_at \ No newline at end of file diff --git a/users/permissions.py b/users/permissions.py new file mode 100644 index 0000000..33a2832 --- /dev/null +++ b/users/permissions.py @@ -0,0 +1,11 @@ +# permissions.py +from rest_framework.permissions import BasePermission + +class IsGithubLinked(BasePermission): + def has_permission(self, request, view): + user = request.user + + if token_obj := getattr(user, 'github_token', None): + if token := getattr(token_obj, 'access_token', None): + return True + return False \ No newline at end of file diff --git a/users/serializers.py b/users/serializers.py index 9110442..d2390a5 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -74,3 +74,7 @@ class UserMemberInfoSerializer(serializers.ModelSerializer): 'custom_url', 'job_and_interests' ] + + +class GitHubCodeSerializer(serializers.Serializer): + code = serializers.CharField() \ No newline at end of file diff --git a/users/services.py b/users/services.py index ca6c318..ba6c573 100644 --- a/users/services.py +++ b/users/services.py @@ -1,3 +1,5 @@ +import requests + from .models import * from projects.models import * from portfolios.models import * @@ -5,6 +7,8 @@ from portfolios.models import * from django.utils import timezone from datetime import timedelta +from django.conf import settings + DUPLICATE_CHECK = { 'email': 'email', @@ -12,6 +16,8 @@ DUPLICATE_CHECK = { 'phone': 'phone' } +GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" + class CheckUserFieldDuplicateService: @staticmethod def check_duplicate(field: str, value: str) -> bool: @@ -92,3 +98,35 @@ class UserToProjectService: @staticmethod def get_scrap_project(user: User): return user.scrapped_projects.filter(is_published=True) + + +class GithubTokenService: + @staticmethod + def exchange_code_to_access_token(code: str) -> dict: + try: + resp = requests.post( + GITHUB_TOKEN_URL, + headers={"Accept": "application/json"}, + data = { + "client_id": settings.GITHUB_CLIENT_ID, + "client_secret": settings.GITHUB_CLIENT_SECRET, + "code": code, + }, + timeout=10, + ) + resp.raise_for_status() + return resp.json() + except requests.HTTPError as e: + raise Exception( + f"Github token exchange failed: {e.response.status_code} - {e.response.text}" + ) + + def github_token_save(data: dict, user: User): + return GithubToken.objects.update_or_create( + user=user, + defaults={ + "access_token": data["access_token"], + "scope": data.get("scope", ""), + "token_type": data.get("token_type", "bearer"), + }, + ) \ No newline at end of file diff --git a/users/urls.py b/users/urls.py index 2f386c8..bad61f2 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,7 +1,12 @@ -from django.urls import path +from django.urls import include, path from .views import * +from rest_framework.routers import DefaultRouter + +router = DefaultRouter(trailing_slash=True) +router.register(r"action", GithubAPIViewSet, basename="github") + app_name = 'users' @@ -17,4 +22,5 @@ urlpatterns = [ path('mypage/profile//', MyPageProfileAPIView.as_view()), path('mypage/works//', MyPageWorkListAPIView.as_view()), path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()), + path("github/", include(router.urls)), ] \ No newline at end of file diff --git a/users/views.py b/users/views.py index 7ea9eea..a192e60 100644 --- a/users/views.py +++ b/users/views.py @@ -8,16 +8,20 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer, Toke from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.exceptions import TokenError, InvalidToken -from rest_framework import status +from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.decorators import action from django.contrib.auth import authenticate from django.db.models import Case, When, Value, IntegerField, Q +from django.utils.functional import cached_property +from django.core.cache import cache from .models import * from .serializers import * from .services import * +from .permissions import * from common.utils.fileManager import file_delete @@ -29,6 +33,10 @@ from django.db import transaction from google.oauth2 import id_token from google.auth.transport import requests +from github import Github, GithubException + +CACHE_TIMEOUT = 60 * 60 +COMMIT_PAGE_SIZE = 20 class RefreshAPIView(APIView): permission_classes = [AllowAny] @@ -107,7 +115,121 @@ class GoogleLoginAPIView(APIView): status=status.HTTP_200_OK ) +class GithubAPIViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated, IsGithubLinked] + # 깃허브 연동 및 api 요청 + def _get_github(self): + uid = self.request.user.id + cache_key = f"gh-token:{uid}" + + token = cache.get(cache_key) + + if token is None: + token = self.request.user.github_token.access_token + cache.set(cache_key, token, timeout=CACHE_TIMEOUT) + + return Github(token) + + @cached_property + def github(self): + return self._get_github() + + @transaction.atomic + @action(detail=False, methods=["post"], url_path="connect", permission_classes=[IsAuthenticated]) + def connect(self, request): + serializer = GithubCodeSerializer(data=request.data) + if serializer.is_valid(): + try: + data = GithubTokenService.exchange_code_to_access_token(serializer.validated_data['code']) + except Exception as e: + return Response({"connected": False, "detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + GithubTokenService.github_token_save(data, request.user) + return Response({"connected": True}, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"], url_path="personal-repos") + def personal_repos(self, request): + user = self.github.get_user() + data = [ + {'full_name': r.full_name} + for r in user.get_repos() + ] + return Response(data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"], url_path="commits") + def commits(self, request): + repo_full_name = request.query_params.get("repo") + if not repo_full_name: + return Response({"is_query_param": False}, status=status.HTTP_400_BAD_REQUEST) + + try: + page = int(request.query_params.get("page", 1)) + except ValueError: + return Response({"is_page_int": False,"detail": "page and page_size must be integers"}, status=status.HTTP_400_BAD_REQUEST) + + if page < 1: + return Response({"is_page_gte_1": False, "detail": "page and page_size must be >= 1"}, status=status.HTTP_400_BAD_REQUEST) + + try: + repo = self.github.get_repo(repo_full_name) + commits = repo.get_commits(per_page=COMMIT_PAGE_SIZE).get_page(page - 1) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + data = [ + {"sha": c.sha, "commit_message": c.commit.message} + for c in commits[:COMMIT_PAGE_SIZE] + ] + + return Response({ + "page": page, + "page_size": COMMIT_PAGE_SIZE, + "results": data + }, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"], url_path="commit-files") + def commit_files(self, request): + repo_full, sha = request.query_params.get("repo"), request.query_params.get("sha") + if not (repo_full and sha): + return Response({"is_query_param": False}, status=status.HTTP_400_BAD_REQUEST) + + commit = self.github.get_repo(repo_full).get_commit(sha) + return Response([{"filename": f.filename, "status": f.status} for f in commit.files]) + + @action(detail=False, methods=["get"], url_path="file-content") + def file_content(self, request): + repo_full = request.query_params.get("repo") + filename = request.query_params.get("filename") + sha = request.query_params.get("sha") + if not (repo_full and filename and sha): + return Response({"is_query_param": False}, status=status.HTTP_400_BAD_REQUEST) + try: + file = self.github.get_repo(repo_full).get_contents(path=filename, ref=sha) + if file.encoding == "base64": + content = file.decoded_content.decode("utf-8", errors="replace") + else: + content = file.content + return Response({"path": file.path, "ref": sha, "content": content}) + except GithubException as e: + return Response({"detail": str(e)}, status=e.status) + + @action(detail=False, methods=["get"], url_path="organizations") + def organizations(self, request): + user = self.github.get_user() + orgs = user.get_orgs() + data = {"org_name": [org.name for org in orgs]} + return Response(data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"], url_path="organization-repos") + def organization_repos(self, request): + org_name = request.query_params.get("org") + repos = self.github.get_organization(org_name).get_repos() + + data = [ + {'full_name': r.full_name} + for r in repos + ] + return Response(data, status=status.HTTP_200_OK) class JoinAPIView(APIView):