Skip to content

Commit

Permalink
Merge branch 'development' of https://github.com/breatheco-de/apiv2 i…
Browse files Browse the repository at this point in the history
…nto development
  • Loading branch information
jefer94 committed Sep 27, 2024
2 parents 02bd717 + 634fa67 commit 774d0a1
Show file tree
Hide file tree
Showing 48 changed files with 1,763 additions and 621 deletions.
4 changes: 3 additions & 1 deletion .gitpod.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ RUN sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release
wget --quiet -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - && \
echo "deb https://apt.llvm.org/$(lsb_release -cs)/ llvm-toolchain-$(lsb_release -cs)-18 main" | sudo tee /etc/apt/sources.list.d/llvm.list && \
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - && \
sudo install-packages postgresql-16 postgresql-contrib-16 redis-server netcat
sudo install-packages postgresql-16 postgresql-contrib-16 redis-server netcat passwd

# Setup PostgreSQL server for user gitpod
ENV PATH="/usr/lib/postgresql/16/bin:$PATH"
Expand All @@ -32,6 +32,8 @@ COPY --chown=gitpod:gitpod postgresql-hook.bash $HOME/.bashrc.d/200-postgresql-l
# RUN pyenv install 3.12.3 && pyenv global 3.12.3
# RUN pip install pipenv

RUN echo "root:1234" | chpasswd

USER gitpod

RUN if ! grep -q "export PIP_USER=no" "$HOME/.bashrc"; then printf '%s\n' "export PIP_USER=no" >> "$HOME/.bashrc"; fi
Expand Down
3 changes: 2 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ zstandard = "*"
psycopg = {extras = ["pool", "binary"] }
cryptography = "*"
adrf = "*"
uvicorn = "*"
django-minify-html = "*"
django-storages = {extras = ["google"] }
aiohttp = {extras = ["speedups"] }
Expand All @@ -156,3 +155,5 @@ python-dotenv = "*"
uvicorn-worker = "*"
pyright = "*"
mypy = "*"
python-magic = "*"
uvicorn = {extras = ["standard"], version = "*"}
897 changes: 580 additions & 317 deletions Pipfile.lock

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions breathecode/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""

# the rest of your ASGI file contents go here
import os

from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "breathecode.settings")

application = get_asgi_application()
http_application = get_asgi_application()

from .websocket.router import routes

application = ProtocolTypeRouter(
{
"http": http_application,
"websocket": routes,
# Just HTTP for now. (We can add other protocols later.)
}
)
4 changes: 3 additions & 1 deletion breathecode/assignments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from adrf.views import APIView
from asgiref.sync import sync_to_async
from capyc.rest_framework.exceptions import ValidationException
from circuitbreaker import CircuitBreakerError
from django.contrib import messages
from django.db.models import Q
Expand Down Expand Up @@ -31,7 +32,6 @@
from breathecode.utils.decorators.capable_of import acapable_of
from breathecode.utils.i18n import translation
from breathecode.utils.multi_status_response import MultiStatusResponse
from capyc.rest_framework.exceptions import ValidationException

from .actions import deliver_task, sync_cohort_tasks
from .caches import TaskCache
Expand Down Expand Up @@ -62,6 +62,8 @@
"application/pdf",
"image/jpg",
"application/octet-stream",
"application/json",
"text/plain",
]

IMAGES_MIME_ALLOW = ["image/png", "image/svg+xml", "image/jpeg", "image/jpg"]
Expand Down
5 changes: 3 additions & 2 deletions breathecode/marketing/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
import secrets

from capyc.rest_framework.exceptions import ValidationException
from django import forms
from django.contrib import admin, messages
from django.contrib.admin import SimpleListFilter
Expand All @@ -10,7 +11,6 @@
from breathecode.services.activecampaign import ActiveCampaign
from breathecode.utils import AdminExportCsvMixin
from breathecode.utils.admin import change_field
from capyc.rest_framework.exceptions import ValidationException

from .actions import (
bind_formentry_with_webhook,
Expand Down Expand Up @@ -255,11 +255,12 @@ class FormEntryAdmin(admin.ModelAdmin, AdminExportCsvMixin):
"buenosaires-argentina",
"caracas-venezuela",
"online",
"4geeks-com",
],
name="location",
)
+ change_field(["full-stack", "datascience-ml", "cybersecurity"], name="course")
+ change_field(["REJECTED", "DUPLICATED", "ERROR"], name="storage_status")
+ change_field(["REJECTED", "DUPLICATED", "ERROR", "MANUALLY_PERSISTED"], name="storage_status")
)

def _attribution_id(self, obj):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 5.1.1 on 2024-09-24 16:29

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("marketing", "0086_coursetranslation_landing_variables"),
]

operations = [
migrations.AlterField(
model_name="formentry",
name="storage_status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("PERSISTED", "Persisted"),
("REJECTED", "Rejected"),
("DUPLICATED", "Duplicated"),
("ERROR", "Error"),
],
default="PENDING",
help_text="MANUALLY_PERSISTED means it was copy pasted into active campaign",
max_length=15,
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 5.1.1 on 2024-09-24 21:17

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("marketing", "0087_alter_formentry_storage_status"),
]

operations = [
migrations.AlterField(
model_name="formentry",
name="storage_status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("PERSISTED", "Persisted"),
("REJECTED", "Rejected"),
("DUPLICATED", "Duplicated"),
("ERROR", "Error"),
],
default="PENDING",
help_text="MANUALLY_PERSISTED means it was copy pasted into active campaign",
max_length=20,
),
),
]
8 changes: 7 additions & 1 deletion breathecode/marketing/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ def save(self, *args, **kwargs):
DUPLICATED = "DUPLICATED"
REJECTED = "REJECTED"
ERROR = "ERROR"
MANUAL = "MANUALLY_PERSISTED"
STORAGE_STATUS = (
(PENDING, "Pending"),
(PERSISTED, "Persisted"),
Expand Down Expand Up @@ -402,7 +403,12 @@ def __init__(self, *args, **kwargs):
sex = models.CharField(max_length=15, null=True, default=None, blank=True, help_text="M=male,F=female,O=other")

# is it saved into active campaign?
storage_status = models.CharField(max_length=15, choices=STORAGE_STATUS, default=PENDING)
storage_status = models.CharField(
max_length=20,
choices=STORAGE_STATUS,
default=PENDING,
help_text="MANUALLY_PERSISTED means it was copy pasted into active campaign",
)
storage_status_text = models.CharField(
default="",
blank=True,
Expand Down
126 changes: 101 additions & 25 deletions breathecode/media/settings.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import os
from io import BytesIO
from typing import Any, Awaitable, Callable, Optional, Type, TypedDict
from typing import Any, Awaitable, Callable, Literal, Optional, Type, TypedDict

from adrf.requests import AsyncRequest
from capyc.rest_framework.exceptions import ValidationException
from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile
from PIL import Image

from breathecode.authenticate.actions import get_user_settings
from breathecode.media.models import Chunk, File
from breathecode.notify.models import Notification
from breathecode.services.google_cloud.storage import Storage
from capyc.rest_framework.exceptions import ValidationException
from breathecode.utils.i18n import translation

type TypeValidator = Callable[[str, Any], None]
type TypeValidatorWrapper = Callable[[Type[Any]], TypeValidator]
Expand All @@ -22,7 +26,7 @@ class MediaSettings(TypedDict):
is_mime_supported: Callable[[InMemoryUploadedFile | TemporaryUploadedFile, Optional[int]], Awaitable[bool]]
get_schema = Optional[Callable[[AsyncRequest, Optional[int]], Awaitable[Schema]]]
# this callback is sync because it'll be called within celery what doesn't support async operations properly
process = Optional[Callable[[File, dict[str, Any], Optional[int]], None]]
process = Optional[Callable[[File, dict[str, Any], Optional[int]], tuple[Literal["INFO", "WARNING", "ERROR"], str]]]


MEDIA_MIME_ALLOWED = [
Expand Down Expand Up @@ -52,11 +56,30 @@ class MediaSettings(TypedDict):
"image/jpeg",
]

EXT_MAP = {
# 'mime': 'format',
"image/gif": "gif",
"image/x-icon": "ico",
"image/jpeg": "jpeg",
# 'image/svg+xml': 'svg', not have sense resize a svg
# 'image/tiff': 'tiff', don't work
"image/webp": "webp",
"image/png": "png",
}


async def allow_any(request: AsyncRequest, academy_id: Optional[int] = None) -> bool:
return True


async def dont_allow_users(request: AsyncRequest, academy_id: Optional[int] = None) -> bool:
return academy_id is not None


async def dont_allow_academies(request: AsyncRequest, academy_id: Optional[int] = None) -> bool:
return academy_id is None


async def no_quota_limit(request: AsyncRequest, academy_id: Optional[int] = None) -> bool:
return False

Expand Down Expand Up @@ -134,15 +157,36 @@ def del_temp_file(file: File | Chunk):
uploaded_file.delete()


def process_media(file: File) -> None:
def get_file(file: File) -> BytesIO:
storage = Storage()
uploaded_file = storage.file(file.bucket, file.file_name)
if uploaded_file.exists() is False:
raise Exception("File does not exists")

f = BytesIO()
uploaded_file.download(f)
return f


def save_file(f: BytesIO, bucket: str, name: str, mime: str) -> str:
storage = Storage()
file = storage.file(bucket, name)
if file.exists() is True:
return file.url()

file.upload(f, content_type=mime)
return file.url()


def process_media(file: File) -> tuple[Literal["INFO", "WARNING", "ERROR"], str]:
from .models import Category, Media

academy_id = file.academy.id if file.academy else None
meta = file.meta

if Media.objects.filter(hash=file.hash, academy__id=academy_id).exists():
del_temp_file(file)
return
return Notification.info("Media already exists")

if url := Media.objects.filter(hash=file.hash).values_list("url", flat=True).first():
del_temp_file(file)
Expand All @@ -162,17 +206,50 @@ def process_media(file: File) -> None:

categories = Category.objects.filter(slug__in=meta["categories"])
media.categories.set(categories)
return Notification.info("Media processed")


def process_profile(file: File) -> tuple[Literal["INFO", "WARNING", "ERROR"], str]:
from breathecode.authenticate.models import Profile

f = get_file(file)
image = Image.open(f)
width, height = image.size

user = file.user
settings = get_user_settings(user.id)
lang = settings.lang

if width != height:
return Notification.error(
translation(lang, en="Profile picture must be square", es="La foto de perfil debe ser cuadrada")
)

# disabled
# def process_profile(file: File, meta: dict[str, Any]) -> None:
# url = transfer(file, os.getenv("MEDIA_GALLERY_BUCKET"))
# storage = Storage()
# cloud_file = storage.file(get_profile_bucket(), hash)
# cloud_file_thumbnail = storage.file(get_profile_bucket(), f"{hash}-100x100")
size = 120
image.resize((size, size))

# if thumb_exists := cloud_file_thumbnail.exists():
# cloud_file_thumbnail_url = cloud_file_thumbnail.url()
resized_image = BytesIO()
ext = EXT_MAP[file.mime]
image.save(resized_image, format=ext)
f.close()
name = f"{file.file_name}-{size}x{size}"

url = save_file(resized_image, os.getenv("PROFILE_BUCKET"), name, file.mime)
resized_image.close()

profile = Profile.objects.filter(user=user).first()
if profile and profile.avatar_url == url:
return Notification.info(
translation(lang, en="You uploaded the same profile picture", es="Subiste la misma foto de perfil")
)

elif profile is None:
profile = Profile(user=user)

profile.avatar_url = url
profile.save()

return Notification.info(translation(lang, en="Profile picture was updated", es="Foto de perfil fue actualizada"))


MB = 1024 * 1024
Expand All @@ -184,7 +261,7 @@ def process_media(file: File) -> None:
"chunk_size": CHUNK_SIZE,
"max_chunks": None,
"is_quota_exceeded": no_quota_limit,
"is_authorized": allow_any,
"is_authorized": dont_allow_users,
"is_mime_supported": media_is_mime_supported,
"get_schema": media_schema,
"process": process_media,
Expand All @@ -193,19 +270,18 @@ def process_media(file: File) -> None:
"chunk_size": CHUNK_SIZE,
"max_chunks": None,
"is_quota_exceeded": no_quota_limit,
"is_authorized": allow_any,
"is_authorized": dont_allow_users,
"is_mime_supported": proof_of_payment_is_mime_supported,
"get_schema": None,
"process": None,
},
# disabled
# "profile-pictures": {
# "chunk_size": CHUNK_SIZE,
# "max_chunks": 25, # because currently it accepts 4K photos
# "is_quota_exceeded": no_quota_limit, # change it in a future
# "is_authorized": allow_any,
# "is_mime_supported": profile_is_mime_supported,
# "schema": no_schema,
# "process": process_profile,
# },
"profile-picture": {
"chunk_size": CHUNK_SIZE,
"max_chunks": 25, # because currently it accepts 4K photos
"is_quota_exceeded": no_quota_limit, # change it in a future
"is_authorized": dont_allow_academies,
"is_mime_supported": profile_is_mime_supported,
"get_schema": None,
"process": process_profile,
},
}
Loading

0 comments on commit 774d0a1

Please sign in to comment.