Feat: [#72] 깃허브 관련 기능 구현 완료

This commit is contained in:
sm4640
2025-07-16 00:14:04 +09:00
parent c2b4f75dcd
commit 873db8d894
7 changed files with 218 additions and 2 deletions

View File

@@ -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

11
users/permissions.py Normal file
View File

@@ -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

View File

@@ -74,3 +74,7 @@ class UserMemberInfoSerializer(serializers.ModelSerializer):
'custom_url',
'job_and_interests'
]
class GitHubCodeSerializer(serializers.Serializer):
code = serializers.CharField()

View File

@@ -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"),
},
)

View File

@@ -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/<str:nickname>/', MyPageProfileAPIView.as_view()),
path('mypage/works/<str:nickname>/', MyPageWorkListAPIView.as_view()),
path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()),
path("github/", include(router.urls)),
]

View File

@@ -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):