Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions articles/ai_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import logging

from celery.result import AsyncResult
from ninja import Router
from ninja.responses import codes_4xx, codes_5xx

from articles.models import Article
from articles.schemas import Message
from myapp.celery import app as celery_app
from myapp.services.ai_tasks import analyse_article_task
from users.auth import JWTAuth

router = Router(tags=["Article AI"])

logger = logging.getLogger(__name__)


@router.post(
"/{article_slug}/ai-summarize",
response={202: dict, codes_4xx: Message, codes_5xx: Message},
auth=JWTAuth(),
)
def queue_ai_analysis(request, article_slug: str):
"""Queue an AI-powered summary and keyword extraction for an article."""
try:
article = Article.objects.get(slug=article_slug)
except Article.DoesNotExist:
return 404, {"message": "Article not found."}

if not article.abstract:
return 400, {"message": "Article has no abstract to analyse."}

task = analyse_article_task.delay(article.id, article.abstract)
return 202, {"task_id": task.id}


@router.get(
"/ai-task/{task_id}",
response={200: dict, codes_4xx: Message, codes_5xx: Message},
auth=JWTAuth(),
)
def get_ai_task_result(request, task_id: str):
"""Poll the status of a queued AI analysis task."""
task = AsyncResult(task_id, app=celery_app)

if task.state == "PENDING":
return 200, {"status": "pending", "result": None}
if task.state == "FAILURE":
return 200, {"status": "failed", "result": None}
if task.successful():
return 200, {"status": "complete", "result": task.result}
return 200, {"status": task.state.lower(), "result": None}
4 changes: 2 additions & 2 deletions articles/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from collections import defaultdict
from datetime import timedelta
from typing import Counter, List, Optional
from urllib.parse import quote_plus, unquote
from urllib.parse import quote, quote_plus, unquote

from django.contrib.contenttypes.models import ContentType
from django.core.paginator import Paginator
Expand Down Expand Up @@ -168,7 +168,7 @@ def create_article(
f"New article submitted in {community.name}"
f" by {request.auth.username}"
),
link=f"/community/{community.name}/submissions",
link=f"/community/{quote(community.name, safe='')}/submissions",
content=article.title,
)
except Exception:
Expand Down
21 changes: 19 additions & 2 deletions communities/api_join.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import logging
from typing import List, Literal
from urllib.parse import quote

from django.utils import timezone
from ninja import Router
from ninja.responses import codes_4xx, codes_5xx

from communities.models import Community, JoinRequest
from communities.schemas import JoinRequestSchema, Message
from myapp.services.send_emails import send_join_decision_email, send_join_request_email
from users.auth import JWTAuth
from users.models import Notification

Expand Down Expand Up @@ -124,13 +126,18 @@ def join_community(request, community_id: int):
community=community,
notification_type="join_request_received",
message=f"New join request from {user.username}",
link=f"/community/{community.name}/requests",
link=f"/community/{quote(community.name, safe='')}/requests",
)
except Exception as e:
logger.error(f"Error creating notification: {e}")
# Continue even if notification fails
pass

try:
send_join_request_email(user, community)
except Exception as e:
logger.error(f"Error sending join request email: {e}")

return 200, {"message": "Your request to join the community has been sent."}
except Exception as e:
logger.error(f"Error processing join request: {e}")
Expand Down Expand Up @@ -204,13 +211,18 @@ def manage_join_request(
community=community,
notification_type="join_request_approved",
message=f"Your join request to {community.name} has been approved.",
link=f"/community/{community.name}",
link=f"/community/{quote(community.name, safe='')}",
)
except Exception as e:
logger.error(f"Error creating notification: {e}")
# Continue even if notification fails
pass

try:
send_join_decision_email(join_request.user, community, "approve")
except Exception as e:
logger.error(f"Error sending join decision email: {e}")

return 200, {
"message": f"Join request approved. \
{join_request.user.username} is now a member of the community."
Expand All @@ -227,6 +239,11 @@ def manage_join_request(
"message": "Error updating join request status. Please try again."
}

try:
send_join_decision_email(join_request.user, community, "reject")
except Exception as e:
logger.error(f"Error sending join decision email: {e}")

return 200, {
"message": "You have rejected the join request successfully."
}
Expand Down
3 changes: 2 additions & 1 deletion communities/articles_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from typing import List, Literal, Optional
from urllib.parse import quote

from django.core.paginator import Paginator
from django.db import transaction
Expand Down Expand Up @@ -119,7 +120,7 @@ def submit_article(request, community_name: str, article_slug: str):
message=(
f"New article submitted in {community.name} by {request.auth.username}"
),
link=f"/community/{community.name}/submissions",
link=f"/community/{quote(community.name, safe='')}/submissions",
content=article.title,
)
except Exception as e:
Expand Down
2 changes: 2 additions & 0 deletions myapp/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from ninja import NinjaAPI, Router
from ninja.errors import AuthenticationError, HttpError, HttpRequest, ValidationError

from articles.ai_api import router as articles_ai_router
from articles.api import router as articles_router
from articles.discussion_api import router as articles_discussion_router
from articles.review_api import router as articles_review_router
Expand Down Expand Up @@ -90,6 +91,7 @@ def generic_error_handler(request: HttpRequest, exc: Exception):
articles_parent_router.add_router("", articles_router)
articles_parent_router.add_router("", articles_review_router)
articles_parent_router.add_router("", articles_discussion_router)
articles_parent_router.add_router("", articles_ai_router)

# Create a parent router to aggregate all community-related endpoints
communities_parent_router = Router()
Expand Down
72 changes: 72 additions & 0 deletions myapp/services/ai_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import json
import logging

import requests
from celery import shared_task
from django.conf import settings

logger = logging.getLogger(__name__)


def _get_ollama_base_url():
return getattr(settings, "OLLAMA_BASE_URL", "http://localhost:11434")


def _get_ollama_model():
return getattr(settings, "OLLAMA_MODEL", "llama3.2")


def _call_ollama(prompt: str) -> str:
"""Send a prompt to the local Ollama instance and return the response text."""
url = f"{_get_ollama_base_url()}/api/generate"
payload = {
"model": _get_ollama_model(),
"prompt": prompt,
"stream": False,
}
response = requests.post(url, json=payload, timeout=120)
response.raise_for_status()
return response.json()["response"].strip()


@shared_task(bind=True, max_retries=2)
def analyse_article_task(self, article_id: int, abstract: str):
"""
Use a locally hosted Ollama model to summarise an article abstract
and extract its key topics. Returns a dict with 'summary' and 'keywords'.
"""
try:
summary_prompt = (
f"Summarise the following scientific abstract in 2-3 sentences "
f"for a general academic audience. Return only the summary text, "
f"no preamble.\n\nAbstract:\n{abstract}"
)
summary = _call_ollama(summary_prompt)

keyword_prompt = (
f"Extract 5 to 8 key topic keywords from the following scientific "
f"abstract. Return them as a JSON array of lowercase strings, "
f"with no additional text.\n\nAbstract:\n{abstract}"
)
raw_keywords = _call_ollama(keyword_prompt)

try:
keywords = json.loads(raw_keywords)
if not isinstance(keywords, list):
keywords = []
except json.JSONDecodeError:
keywords = []

return {"article_id": article_id, "summary": summary, "keywords": keywords}

except requests.exceptions.ConnectionError as exc:
logger.error("Ollama is not reachable at %s: %s", _get_ollama_base_url(), exc)
raise self.retry(exc=exc, countdown=10)

except requests.exceptions.Timeout as exc:
logger.error("Ollama request timed out for article %s: %s", article_id, exc)
raise self.retry(exc=exc, countdown=30)

except Exception as exc:
logger.error("AI analysis failed for article %s: %s", article_id, exc)
raise
81 changes: 81 additions & 0 deletions myapp/services/send_emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,87 @@ def send_review_notification_email(article, review, community):
logger.error(f"Error sending review notification email: {e}")


def send_join_request_email(user, community):
admin = community.admins.first()
if not admin or not admin.email:
logger.warning(
f"Cannot send join request email: community {community.id} has no admin with email"
)
return

if not is_email_notifications_enabled(admin.id):
logger.debug(
f"Email notifications disabled for admin {admin.id}, skipping join request email"
)
return

domain = get_frontend_domain()
link = f"{domain}/community/{quote(community.name, safe='')}/requests"

context = {
"recipient_name": admin.first_name or admin.username,
"notification_type": "New Join Request",
"message_text": mark_safe(
f"<b>{user.username}</b> has requested to join the <b>{community.name}</b> community."
),
"content_preview": None,
"article_link": link,
}

send_email_task.delay(
subject=f"New Join Request for {community.name}",
html_template_name="review_comment_notification.html",
context=context,
recipient_list=[admin.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)


def send_join_decision_email(user, community, action):
if not user or not user.email:
logger.warning(
f"Cannot send join decision email: user has no email for community {community.id}"
)
return

if not is_email_notifications_enabled(user.id):
logger.debug(
f"Email notifications disabled for user {user.id}, skipping join decision email"
)
return

domain = get_frontend_domain()

if action == "approve":
notification_type = "Join Request Approved"
message_text = mark_safe(
f"Your request to join <b>{community.name}</b> has been approved. Welcome!"
)
link = f"{domain}/community/{quote(community.name, safe='')}"
else:
notification_type = "Join Request Rejected"
message_text = mark_safe(
f"Your request to join <b>{community.name}</b> has been rejected."
)
link = f"{domain}/communities"

context = {
"recipient_name": user.first_name or user.username,
"notification_type": notification_type,
"message_text": message_text,
"content_preview": None,
"article_link": link,
}

send_email_task.delay(
subject=f"Community Join Request {action.capitalize()}d: {community.name}",
html_template_name="review_comment_notification.html",
context=context,
recipient_list=[user.email],
from_email=settings.DEFAULT_FROM_EMAIL,
)


def send_comment_notification_email(comment, review, article, community):
"""
Send email notification when a new comment/reply is added to a review.
Expand Down
4 changes: 2 additions & 2 deletions myapp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@
"formatters": {
"detailed": {
"format": LOG_FORMAT,
"datefmt": "%Y-%m-%d %H:%M:%S,%f",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
Expand Down Expand Up @@ -377,7 +377,7 @@
"formatters": {
"detailed": {
"format": LOG_FORMAT,
"datefmt": "%Y-%m-%d %H:%M:%S,%f",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"handlers": {
Expand Down
6 changes: 3 additions & 3 deletions users/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class AcademicStatusSchema(Schema):
class UserBasicDetails(Schema):
id: int
username: str
profile_pic_url: str
profile_pic_url: Optional[str]

class Config:
model = User
Expand All @@ -92,7 +92,7 @@ def from_model(user: User):
return {
"id": user.id,
"username": user.username,
"profile_pic_url": user.profile_pic_url,
"profile_pic_url": user.profile_pic_url.url if user.profile_pic_url else None,
}


Expand Down Expand Up @@ -131,7 +131,7 @@ def resolve_user(user: User):
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"profile_pic_url": user.profile_pic_url,
"profile_pic_url": user.profile_pic_url.url if user.profile_pic_url else None,
"pubMed_url": user.pubMed_url,
"google_scholar_url": user.google_scholar_url,
"bio": user.bio,
Expand Down