diff --git a/zubhub_backend/compose/celery/requirements.txt b/zubhub_backend/compose/celery/requirements.txt index 4dc0eea12..c8adf459e 100644 --- a/zubhub_backend/compose/celery/requirements.txt +++ b/zubhub_backend/compose/celery/requirements.txt @@ -82,6 +82,7 @@ uritemplate>=3.0.1 urllib3>=1.25.11 vine>=1.3.0 watchdog>=0.10.2 +weasyprint>=60.2 wcwidth>=0.2.5 whitenoise>=4.1.4 django-extensions>=1.0.0 diff --git a/zubhub_backend/compose/web/dev/Dockerfile b/zubhub_backend/compose/web/dev/Dockerfile index 22655bdfe..4182c8047 100644 --- a/zubhub_backend/compose/web/dev/Dockerfile +++ b/zubhub_backend/compose/web/dev/Dockerfile @@ -11,6 +11,8 @@ RUN apt-get update \ && apt-get install -y libpq-dev \ # Translations dependencies && apt-get install -y gettext \ + # dependencies of Weasyprint + && apt install python3-pip python3-cffi python3-brotli libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 -y\ # cleaning up unused files && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ && rm -rf /var/lib/apt/lists/* diff --git a/zubhub_backend/compose/web/requirements.txt b/zubhub_backend/compose/web/requirements.txt index dacef1dc8..ce0341f64 100644 --- a/zubhub_backend/compose/web/requirements.txt +++ b/zubhub_backend/compose/web/requirements.txt @@ -71,6 +71,7 @@ python-dateutil>=2.8.1 python3-openid>=3.2.0 pytz>=2020.4 PyYAML>=5.4 +qrcode==7.4.2 requests>=2.23.0 requests-oauthlib>=1.3.0 s3transfer>=0.3.3 @@ -85,5 +86,6 @@ uritemplate>=3.0.1 urllib3>=1.25.11 vine>=1.3.0 watchdog>=0.10.2 +weasyprint==52.4 wcwidth>=0.2.5 whitenoise>=4.1.4 diff --git a/zubhub_backend/zubhub/activities/urls.py b/zubhub_backend/zubhub/activities/urls.py index 927aa0e13..9397357e0 100644 --- a/zubhub_backend/zubhub/activities/urls.py +++ b/zubhub_backend/zubhub/activities/urls.py @@ -12,5 +12,6 @@ path('/delete/', ActivityDeleteAPIView.as_view(), name='delete'), path('/toggle-save/', ToggleSaveAPIView.as_view(), name='save'), path('/toggle-publish/', togglePublishActivityAPIView.as_view(), name='publish'), - path('/', ActivityDetailsAPIView.as_view(), name='detail_activity') + path('/', ActivityDetailsAPIView.as_view(), name='detail_activity'), + path('/pdf/', DownloadActivityPDF.as_view(), name='pdf'), ] diff --git a/zubhub_backend/zubhub/activities/utils.py b/zubhub_backend/zubhub/activities/utils.py index 21e4d8014..921366dd7 100644 --- a/zubhub_backend/zubhub/activities/utils.py +++ b/zubhub_backend/zubhub/activities/utils.py @@ -1,3 +1,10 @@ +import io +import base64 +import qrcode +import requests +from django.http import HttpResponse +from django.template.loader import get_template +from weasyprint import HTML from .models import * @@ -34,21 +41,20 @@ def create_inspiring_examples(activity, inspiring_examples): def create_activity_images(activity, images): - for image in images: saved_image = Image.objects.create(**image['image']) ActivityImage.objects.create(activity=activity, image=saved_image) def update_image(image, image_data): - if(image_data is not None and image is not None): + if (image_data is not None and image is not None): if image_data["file_url"] == image.file_url: return image else: image.delete() return Image.objects.create(**image_data) else: - if(image): + if (image): image.delete() else: return Image.objects.create(**image_data) @@ -67,3 +73,79 @@ def update_making_steps(activity, making_steps): def update_inspiring_examples(activity, inspiring_examples): InspiringExample.objects.filter(activity=activity).delete() create_inspiring_examples(activity, inspiring_examples) + + +def generate_qr_code(link): + """ + Generate a QR code for a given link and return it as a base64 string. + + Args: + link (str): The link to encode in the QR code. + + Returns: + str: The QR code as a base64 string. + """ + # Generate QR code + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(link) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + buf = io.BytesIO() + img.save(buf, format="PNG") + + img_bytes = buf.getvalue() + + img_base64 = base64.b64encode(img_bytes).decode() + + return img_base64 + + +def generate_pdf(template_path, context): + """ + Generate a PDF file from a Jinja template. + + Args: + template_path (str): The file path to the Jinja template. + context (dict): The context data for rendering the template. + + Returns: + HttpResponse: A Django HTTP response with the generated PDF. + """ + template = get_template(template_path) + + html = template.render(context) + + pdf = HTML(string=html).write_pdf() + + activity_id = context['activity_id'] + + response = HttpResponse(pdf, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{activity_id}.pdf"' + + return response + + +def download_file(file_url): + """ + Download a file from a given URL and save it to the local filesystem. + + Args: + file_url (str): The URL of the file to download. + + Returns: + bytes: The file data. + """ + response = requests.get(file_url, stream=True) + response.raise_for_status() + file_data = b"" + for chunk in response.iter_content(chunk_size=4096): + if chunk: + file_data += chunk + return file_data diff --git a/zubhub_backend/zubhub/activities/views.py b/zubhub_backend/zubhub/activities/views.py index 7a7f015b7..b650d57f0 100644 --- a/zubhub_backend/zubhub/activities/views.py +++ b/zubhub_backend/zubhub/activities/views.py @@ -1,18 +1,16 @@ -from django.shortcuts import render -from django.utils.translation import ugettext_lazy as _ -from rest_framework.decorators import api_view -from rest_framework.response import Response from rest_framework.generics import ( ListAPIView, CreateAPIView, RetrieveAPIView, UpdateAPIView, DestroyAPIView) from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly, AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView from .permissions import IsStaffOrModeratorOrEducator, IsOwner, IsStaffOrModerator from django.shortcuts import get_object_or_404 from .models import * from .serializers import * from django.db import transaction -from django.core.exceptions import PermissionDenied from django.contrib.auth.models import AnonymousUser - +from .utils import generate_pdf, generate_qr_code, download_file +from django.conf import settings class ActivityListAPIView(ListAPIView): @@ -23,7 +21,7 @@ class ActivityListAPIView(ListAPIView): def get_queryset(self): all = Activity.objects.all() return all - + class UserActivitiesAPIView(ListAPIView): """ @@ -33,7 +31,7 @@ class UserActivitiesAPIView(ListAPIView): serializer_class = ActivitySerializer permission_classes = [IsAuthenticated, IsOwner] - + def get_queryset(self): return self.request.user.activities_created.all() @@ -53,7 +51,7 @@ def get_object(self): queryset = self.get_queryset() pk = self.kwargs.get("pk") obj = get_object_or_404(queryset, pk=pk) - + if obj: with transaction.atomic(): if isinstance(self.request.user, AnonymousUser): @@ -65,7 +63,7 @@ def get_object(self): obj.views_count += 1 obj.save() return obj - + else: raise Exception() @@ -77,7 +75,7 @@ class PublishedActivitiesAPIView(ListAPIView): serializer_class = ActivitySerializer permission_classes = [AllowAny] - + def get_queryset(self): limit = self.request.query_params.get('limit', 10000) @@ -87,7 +85,7 @@ def get_queryset(self): limit = 10 return Activity.objects.filter(publish= True)[:limit] - + class UnPublishedActivitiesAPIView(ListAPIView): """ Fetch list of unpublished activities by authenticated staff member. @@ -100,7 +98,7 @@ class UnPublishedActivitiesAPIView(ListAPIView): permission_classes = [IsAuthenticated, IsStaffOrModerator] def get_queryset(self): - return Activity.objects.filter(publish= False) + return Activity.objects.filter(publish= False) class ActivityCreateAPIView(CreateAPIView): """ @@ -150,11 +148,11 @@ class ToggleSaveAPIView(RetrieveAPIView): queryset = Activity.objects.all() serializer_class = ActivitySerializer permission_classes = [IsAuthenticated] - + def get_object(self): pk = self.kwargs.get("pk") obj = get_object_or_404(self.get_queryset(), pk=pk) - + if self.request.user in obj.saved_by.all(): obj.saved_by.remove(self.request.user) obj.save() @@ -173,12 +171,59 @@ class togglePublishActivityAPIView(RetrieveAPIView): queryset = Activity.objects.all() serializer_class = ActivitySerializer permission_classes = [IsAuthenticated, IsStaffOrModerator] - + def get_object(self): - + pk = self.kwargs.get("pk") - obj = get_object_or_404(self.get_queryset(), pk=pk) + obj = get_object_or_404(self.get_queryset(), pk=pk) obj.publish = not obj.publish obj.save() return obj + + + +class DownloadActivityPDF(APIView): + """ + Download an activities. + Requires activities id. + Returns activities file. + """ + queryset = Activity.objects.all() + template_path = 'activities/activity_download.html' + + + def get_queryset(self): + return self.queryset + + def get_object(self): + pk = self.kwargs.get("pk") + obj = get_object_or_404(self.get_queryset(), pk=pk) + return obj + + def get(self, request, *args, **kwargs): + activity = self.get_object() + activity_images = ActivityImage.objects.filter(activity=activity) + activity_steps = ActivityMakingStep.objects.filter(activity=activity) + if settings.ENVIRONMENT == 'production': + qr_code = generate_qr_code( + link=f"https://zubhub.unstructured.studio/activities/{activity.id}" + ) + else: + qr_code = generate_qr_code( + link=f"{settings.DEFAULT_BACKEND_PROTOCOL}//{settings.DEFAULT_BACKEND_DOMAIN}/activities/{activity.id}" + ) + context = { + 'activity': activity, + 'activity_id': activity.id, + 'activity_images': activity_images, + 'activity_steps': activity_steps, + 'activity_steps_images': [step.image.all() for step in activity_steps], + 'activity_category': [category.name for category in activity.category.all()], + 'creators': [creator for creator in activity.creators.all()], + 'qr_code': qr_code + } + return generate_pdf( + template_path=self.template_path, + context=context + ) diff --git a/zubhub_backend/zubhub/templates/activities/activity_download.html b/zubhub_backend/zubhub/templates/activities/activity_download.html new file mode 100644 index 000000000..cf6e5cac5 --- /dev/null +++ b/zubhub_backend/zubhub/templates/activities/activity_download.html @@ -0,0 +1,318 @@ + + + + + + + + +
+
+
+ {{ activity.title }} +
+
+ {% if creators %} + {% for creator in creators %} + {{ creator.username }} avatar + {{ creator.username }} + {% endfor %} + {% endif %} +
+
+
+

+ Introduction +

+
+ {{ activity.introduction | safe }} +
+
+ {% for activity_image in activity_images %} + + + + {% endfor %} +
+
+
+

+ Category +

+
+ {% for category in activity_category %} + {{ category }} + {% endfor %} +
+
+
+

+ Class Grade +

+
+ {{ activity.class_grade }} +
+
+ {% if activity.materials_used %} +
+

Material Used

+
+ {{ activity.materials_used | safe }} +
+
+ + + +
+
+ {% endif %} + {% if activity_steps %} + {% for step in activity_steps %} +
+

+ Step {{ step.step_order }}: {{ step.title }} +

+
+ {{ step.description | safe }} +
+
+ {% for img in activity_steps_images %} + + Activity image + + {% endfor %} +
+
+ {% endfor %} + {% endif %} +
+ + + diff --git a/zubhub_frontend/zubhub/public/locales/en/translation.json b/zubhub_frontend/zubhub/public/locales/en/translation.json index 983199552..51676c9e8 100644 --- a/zubhub_frontend/zubhub/public/locales/en/translation.json +++ b/zubhub_frontend/zubhub/public/locales/en/translation.json @@ -1098,6 +1098,10 @@ "mediaServerError": "Sorry media server is down we couldn't upload your files! try again later", "uploadError": "error occurred while downloading file : " } + }, + "downloadButton": { + "downloading": "Downloading...", + "download": "Download PDF" } }, diff --git a/zubhub_frontend/zubhub/public/locales/hi/translation.json b/zubhub_frontend/zubhub/public/locales/hi/translation.json index 9ba8ec907..eab9ba7fa 100644 --- a/zubhub_frontend/zubhub/public/locales/hi/translation.json +++ b/zubhub_frontend/zubhub/public/locales/hi/translation.json @@ -1000,7 +1000,11 @@ "mediaServerError": "क्षमा करें मीडिया सर्वर डाउन है हम आपकी फ़ाइलें अपलोड नहीं कर सके! बाद में पुन: प्रयास", "uploadError": "फ़ाइल डाउनलोड करते समय त्रुटि हुई: " } - } + }, + "downloadButton": { + "downloading": "डाउनलोड हो रहा है...", + "download": "डाउनलोड पीडीऍफ़" + } }, "activityDetails": { diff --git a/zubhub_frontend/zubhub/src/api/api.js b/zubhub_frontend/zubhub/src/api/api.js index 52e2abbe1..aff906c9e 100644 --- a/zubhub_frontend/zubhub/src/api/api.js +++ b/zubhub_frontend/zubhub/src/api/api.js @@ -1114,6 +1114,23 @@ class API { const url = `activities/${id}/toggle-publish/`; return this.request({ url, token }).then(res => res.json()); }; + + activityDownload = async ({ id, token }) => { + const url = `${this.domain}activities/${id}/pdf/`; + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Token ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } else { + const blob = await response.blob(); + return blob; + } + }; } export default API; diff --git a/zubhub_frontend/zubhub/src/views/activity_details/ActivityDetailsV2.jsx b/zubhub_frontend/zubhub/src/views/activity_details/ActivityDetailsV2.jsx index 636033227..6e39b6b24 100644 --- a/zubhub_frontend/zubhub/src/views/activity_details/ActivityDetailsV2.jsx +++ b/zubhub_frontend/zubhub/src/views/activity_details/ActivityDetailsV2.jsx @@ -21,8 +21,6 @@ import { useTranslation } from 'react-i18next'; import { FiShare } from 'react-icons/fi'; import ReactQuill from 'react-quill'; import { useSelector } from 'react-redux'; -import { useReactToPrint } from 'react-to-print'; -import Html2Pdf from 'html2pdf.js'; import ZubHubAPI from '../../api'; import { colors } from '../../assets/js/colors'; @@ -87,29 +85,23 @@ export default function ActivityDetailsV2(props) { props.navigate(`${props.location.pathname}/edit`); }; - const handleDownload = useReactToPrint({ - onBeforePrint: () => setIsDownloading(true), - onPrintError: () => setIsDownloading(false), - onAfterPrint: () => setIsDownloading(false), - content: () => ref.current, - removeAfterPrint: true, - print: async printIframe => { - const document = printIframe.contentDocument; - if (document) { - const html = document.getElementsByTagName('html')[0]; - const pdfOptions = { - padding: 10, - filename: `${activity.title}.pdf`, - image: { type: 'jpeg', quality: 0.98 }, - html2canvas: { scale: 2, useCORS: true }, - jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, - }; - - const exporter = new Html2Pdf(html, pdfOptions); - await exporter.getPdf(true); - } - }, - }); + const handleDownload = () => { + setIsDownloading(true); + API.activityDownload({ token: auth.token, id: activity.id }) + .then(res => { + const url = window.URL.createObjectURL(new Blob([res])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${activity.title}.pdf`); + document.body.appendChild(link); + link.click(); + link.remove(); + }) + .catch(error => { + console.error('Error downloading PDF:', error); + }) + .finally(() => setIsDownloading(false)); + }; return (
@@ -149,7 +141,7 @@ export default function ActivityDetailsV2(props) { primaryButtonOutlinedStyle style={{ borderRadius: 4 }} > - {isDownloading ? 'Downloading...' : 'Download PDF'} + {isDownloading ? t('activities.downloadButton.downloading') : t('activities.downloadButton.download')}
diff --git a/zubhub_frontend/zubhub/src/views/activity_details/activityDetailsScripts.js b/zubhub_frontend/zubhub/src/views/activity_details/activityDetailsScripts.js index 5b74c5a09..524781985 100644 --- a/zubhub_frontend/zubhub/src/views/activity_details/activityDetailsScripts.js +++ b/zubhub_frontend/zubhub/src/views/activity_details/activityDetailsScripts.js @@ -4,39 +4,40 @@ import ZubhubAPI from '../../api'; const API = new ZubhubAPI(); -export const deleteActivity = args => { - return API.deleteActivity({ token: args.token, id: args.id }).then(res => { +export const deleteActivity = args => + API.deleteActivity({ token: args.token, id: args.id }).then(res => { if (res.status === 204) { - toast.success(args.t('activityDetails.activity.delete.dialog.success')); + toast.success(args.t('activityDetails.activity.delete.dialog.forbidden')); return args.navigate('/activities'); + } else if (res.status === 403 && res.statusText === 'Forbidden') { + toast.warning(args.t('activityDetails.activity.delete.dialog.forbidden')); } else { - if (res.status === 403 && res.statusText === 'Forbidden') { - toast.warning( - args.t('activityDetails.activity.delete.dialog.forbidden'), - ); - } else { - toast.warning(args.t('activities.errors.dialog.serverError')); - } + toast.warning(args.t('activities.errors.dialog.serverError')); } }); + +export const activityDownload = async args => { + const response = await API.activityDownload({ token: args.token, id: args.id }); + + if (response.status === 200) { + const blob = await response.blob(); + return blob; + } else if (response.status === 403 && response.statusText === 'Forbidden') { + toast.warning(args.t('activityDetails.activity.download.dialog.forbidden')); + } else { + toast.warning(args.t('activities.errors.dialog.serverError')); + } }; -export const togglePublish = async ( - e, - id, - auth, - navigate, - activityTogglePublish, - t, -) => { +export const togglePublish = async (e, id, auth, navigate, activityTogglePublish, t) => { e.preventDefault(); if (!auth.token) { navigate('/login'); } else { - const result = await activityTogglePublish({ + await activityTogglePublish({ id, token: auth.token, - t: t, + t, }); } };