✨ Feat: [#72] 깃허브 관련 기능 구현 완료
This commit is contained in:
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
|
|||||||
'codes',
|
'codes',
|
||||||
'notifications',
|
'notifications',
|
||||||
'nocodetools',
|
'nocodetools',
|
||||||
|
'homes',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
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 s3
|
||||||
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID')
|
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID')
|
||||||
AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY')
|
AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY')
|
||||||
@@ -250,6 +267,9 @@ SESSION_COOKIE_SECURE = True
|
|||||||
|
|
||||||
GOOGLE_CLIENT_ID = env('GOOGLE_CLIENT_ID')
|
GOOGLE_CLIENT_ID = env('GOOGLE_CLIENT_ID')
|
||||||
|
|
||||||
|
GITHUB_CLIENT_ID = env('GITHUB_CLIENT_ID')
|
||||||
|
GITHUB_CLIENT_SECRET = env('GITHUB_CLIENT_SECRET')
|
||||||
|
|
||||||
##CORS
|
##CORS
|
||||||
# CSRF_TRUSTED_ORIGINS = []
|
# CSRF_TRUSTED_ORIGINS = []
|
||||||
# CORS_ALLOWED_ORIGINS = ["http://localhost:3000", "https://{프론트주소}.vercel.app/"]
|
# CORS_ALLOWED_ORIGINS = ["http://localhost:3000", "https://{프론트주소}.vercel.app/"]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
from common.models.baseModels import BaseModel
|
from common.models.baseModels import BaseModel
|
||||||
from common.models.choiceModels import GenderChoices, CertificateCodeUseType
|
from common.models.choiceModels import GenderChoices, CertificateCodeUseType
|
||||||
from common.utils.codeManger import set_expire
|
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 common.models.choiceModels import NotificationType
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
def create_user(self, email, password, **kwargs):
|
def create_user(self, email, password, **kwargs):
|
||||||
user = self.model(email = email, **kwargs)
|
user = self.model(email = email, **kwargs)
|
||||||
@@ -72,3 +76,14 @@ class User(BaseModel, AbstractBaseUser, PermissionsMixin):
|
|||||||
if self.banner_image:
|
if self.banner_image:
|
||||||
self.banner_image.delete(save=False)
|
self.banner_image.delete(save=False)
|
||||||
super().delete(*args, **kwargs)
|
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
11
users/permissions.py
Normal 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
|
||||||
@@ -74,3 +74,7 @@ class UserMemberInfoSerializer(serializers.ModelSerializer):
|
|||||||
'custom_url',
|
'custom_url',
|
||||||
'job_and_interests'
|
'job_and_interests'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubCodeSerializer(serializers.Serializer):
|
||||||
|
code = serializers.CharField()
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import requests
|
||||||
|
|
||||||
from .models import *
|
from .models import *
|
||||||
from projects.models import *
|
from projects.models import *
|
||||||
from portfolios.models import *
|
from portfolios.models import *
|
||||||
@@ -5,6 +7,8 @@ from portfolios.models import *
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
DUPLICATE_CHECK = {
|
DUPLICATE_CHECK = {
|
||||||
'email': 'email',
|
'email': 'email',
|
||||||
@@ -12,6 +16,8 @@ DUPLICATE_CHECK = {
|
|||||||
'phone': 'phone'
|
'phone': 'phone'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||||
|
|
||||||
class CheckUserFieldDuplicateService:
|
class CheckUserFieldDuplicateService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_duplicate(field: str, value: str) -> bool:
|
def check_duplicate(field: str, value: str) -> bool:
|
||||||
@@ -92,3 +98,35 @@ class UserToProjectService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_scrap_project(user: User):
|
def get_scrap_project(user: User):
|
||||||
return user.scrapped_projects.filter(is_published=True)
|
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"),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
|
||||||
from .views import *
|
from .views import *
|
||||||
|
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
router = DefaultRouter(trailing_slash=True)
|
||||||
|
router.register(r"action", GithubAPIViewSet, basename="github")
|
||||||
|
|
||||||
|
|
||||||
app_name = 'users'
|
app_name = 'users'
|
||||||
|
|
||||||
@@ -17,4 +22,5 @@ urlpatterns = [
|
|||||||
path('mypage/profile/<str:nickname>/', MyPageProfileAPIView.as_view()),
|
path('mypage/profile/<str:nickname>/', MyPageProfileAPIView.as_view()),
|
||||||
path('mypage/works/<str:nickname>/', MyPageWorkListAPIView.as_view()),
|
path('mypage/works/<str:nickname>/', MyPageWorkListAPIView.as_view()),
|
||||||
path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()),
|
path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()),
|
||||||
|
path("github/", include(router.urls)),
|
||||||
]
|
]
|
||||||
124
users/views.py
124
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.tokens import RefreshToken
|
||||||
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
|
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.response import Response
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.db.models import Case, When, Value, IntegerField, Q
|
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 .models import *
|
||||||
from .serializers import *
|
from .serializers import *
|
||||||
from .services import *
|
from .services import *
|
||||||
|
from .permissions import *
|
||||||
|
|
||||||
from common.utils.fileManager import file_delete
|
from common.utils.fileManager import file_delete
|
||||||
|
|
||||||
@@ -29,6 +33,10 @@ from django.db import transaction
|
|||||||
from google.oauth2 import id_token
|
from google.oauth2 import id_token
|
||||||
from google.auth.transport import requests
|
from google.auth.transport import requests
|
||||||
|
|
||||||
|
from github import Github, GithubException
|
||||||
|
|
||||||
|
CACHE_TIMEOUT = 60 * 60
|
||||||
|
COMMIT_PAGE_SIZE = 20
|
||||||
|
|
||||||
class RefreshAPIView(APIView):
|
class RefreshAPIView(APIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
@@ -107,7 +115,121 @@ class GoogleLoginAPIView(APIView):
|
|||||||
status=status.HTTP_200_OK
|
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):
|
class JoinAPIView(APIView):
|
||||||
|
|||||||
Reference in New Issue
Block a user