Implement drug refresh and view

master
Mark Veidemanis 8 months ago
parent 37534b31bf
commit 0488a3e0b2
Signed by: m
GPG Key ID: 5ACFCEED46C0904F

@ -61,4 +61,9 @@ urlpatterns = [
drugs.DrugClear.as_view(),
name="drug_clear",
),
path(
"drugs/refresh/all/",
drugs.DrugPullMerge.as_view(),
name="drug_pull_merge",
),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

@ -0,0 +1,205 @@
from abc import ABC, abstractmethod
import aiohttp
import orjson
from glom import glom
from pydantic.error_wrappers import ValidationError
from core.lib import schemas
from core.util import logs
# Return error if the schema for the message type is not found
STRICT_VALIDATION = False
# Raise exception if the conversion schema is not found
STRICT_CONVERSION = False
# TODO: Set them to True when all message types are implemented
log = logs.get_logger("clients")
class NoSchema(Exception):
"""
Raised when:
- The schema for the message type is not found
- The conversion schema is not found
- There is no schema library for the client
"""
pass
class NoSuchMethod(Exception):
"""
Client library has no such method.
"""
pass
class GenericAPIError(Exception):
"""
Generic API error.
"""
pass
def is_camel_case(s):
return s != s.lower() and s != s.upper() and "_" not in s
def snake_to_camel(word):
if is_camel_case(word):
return word
return "".join(x.capitalize() or "_" for x in word.split("_"))
DEFAULT_HEADERS = {
"accept": "application/json",
"Content-Type": "application/json",
}
class BaseClient(ABC):
token = None
async def __new__(cls, *a, **kw):
instance = super().__new__(cls)
await instance.__init__(*a, **kw)
return instance
async def __init__(self):
"""
Initialise the client.
:param instance: the database object, e.g. Aggregator
"""
name = self.__class__.__name__
self.name = name.replace("Client", "").lower()
# self.instance = instance
self.client = None
await self.connect()
@abstractmethod
async def connect(self):
pass
@property
def schema(self):
"""
Get the schema library for the client.
"""
# Does the schemas library have a library for this client name?
if hasattr(schemas, f"{self.name}_s"):
schema_instance = getattr(schemas, f"{self.name}_s")
else:
log.error(f"No schema library for {self.name}")
raise Exception(f"No schema library for client {self.name}")
return schema_instance
def get_schema(self, method, convert=False):
if isinstance(method, str):
to_camel = snake_to_camel(method)
else:
to_camel = snake_to_camel(method.__class__.__name__)
if convert:
to_camel = f"{to_camel}Schema"
# if hasattr(self.schema, method):
# schema = getattr(self.schema, method)
if hasattr(self.schema, to_camel):
schema = getattr(self.schema, to_camel)
else:
raise NoSchema(f"Could not get schema: {to_camel}")
return schema
async def call_method(self, method, *args, **kwargs):
"""
Call a method with aiohttp.
"""
if kwargs.get("append_slash", True):
path = f"{self.url}/{method}/"
else:
path = f"{self.url}/{method}"
http_method = kwargs.get("http_method", "get")
cast = {
"headers": DEFAULT_HEADERS,
}
# Use the token if it's set
if self.token is not None:
cast["headers"]["Authorization"] = f"Bearer {self.token}"
if "data" in kwargs:
cast["data"] = orjson.dumps(kwargs["data"])
# Use the method to send a HTTP request
async with aiohttp.ClientSession() as session:
session_method = getattr(session, http_method)
async with session_method(path, **cast) as response:
response_json = await response.json()
return response_json
def convert_spec(self, response, method):
"""
Convert an API response to the requested spec.
:raises NoSchema: If the conversion schema is not found
"""
schema = self.get_schema(method, convert=True)
# Use glom to convert the response to the schema
converted = glom(response, schema)
return converted
def validate_response(self, response, method):
schema = self.get_schema(method)
# Return a dict of the validated response
try:
response_valid = schema(**response).dict()
except ValidationError as e:
log.error(f"Error validating {method} response: {response}")
log.error(f"Errors: {e}")
raise GenericAPIError("Error validating response")
return response_valid
def method_filter(self, method):
"""
Return a new method.
"""
return method
async def call(self, method, *args, **kwargs):
"""
Call the exchange API and validate the response
:raises NoSchema: If the method is not in the schema mapping
:raises ValidationError: If the response cannot be validated
"""
# try:
response = await self.call_method(method, *args, **kwargs)
# except (APIError, V20Error) as e:
# log.error(f"Error calling method {method}: {e}")
# raise GenericAPIError(e)
if "schema" in kwargs:
method = kwargs["schema"]
else:
method = self.method_filter(method)
try:
response_valid = self.validate_response(response, method)
except NoSchema as e:
log.error(f"{e} - {response}")
response_valid = response
# Convert the response to a format that we can use
try:
response_converted = self.convert_spec(response_valid, method)
except NoSchema as e:
log.error(f"{e} - {response}")
response_converted = response_valid
# return (True, response_converted)
return response_converted

@ -0,0 +1,26 @@
from abc import ABC
from core.models import Source
from core.util import logs
log = logs.get_logger("graphql")
class GraphQLClient(ABC):
"""
GraphQL API handler.
"""
async def connect(self):
try:
source = Source.objects.get(name=self.source_name)
except Source.DoesNotExist:
source = Source(
name=self.source_name,
type=self.source_type,
endpoint=self.source_endpoint,
score=self.source_score,
)
source.save()
self.url = source.endpoint
self.source = source

@ -0,0 +1,297 @@
from core.clients.base import BaseClient
from core.clients.graphql import GraphQLClient
from core.models import SEI, Dosage, Drug, Effect, Entry, Timing
class PsychWikiClient(GraphQLClient, BaseClient):
# url = "https://api.psychonautwiki.org"
search_limit = 5000
source_name = "Psychonaut Wiki GraphQL API"
source_type = "DWIKI"
source_endpoint = "https://api.psychonautwiki.org"
source_score = 75
async def update_drugs(self):
data = await self.get_all_data()
self.store_data(data)
return len(data)
def store_data(self, data):
"""
Store the data in the database.
"""
for drug in data["substances"]:
try:
drug_obj = Drug.objects.get(name=drug["name"])
except Drug.DoesNotExist:
drug_obj = Drug(name=drug["name"])
if "commonNames" in drug:
if drug["commonNames"]:
print("common names", drug["commonNames"])
drug_obj.common_names = ",".join(drug["commonNames"])
if "class" in drug:
if drug["class"]:
if "psychoactive" in drug["class"]:
if drug["class"]["psychoactive"]:
drug_obj.drug_class = ",".join(
drug["class"]["psychoactive"]
)
try:
entry = Entry.objects.get(source=self.source, url=drug["url"])
except Entry.DoesNotExist:
entry = Entry.objects.create(source=self.source, url=drug["url"])
if not drug_obj.pk:
drug_obj.save()
if entry not in drug_obj.links.all():
drug_obj.links.add(entry)
if "roas" in drug:
for roa in drug["roas"]:
if "name" in roa:
roa_name = roa["name"]
# Parsing dosage information
dose = roa["dose"]
if dose:
dosage_data = {
"entry": entry,
"roa": roa_name,
"unit": dose["units"],
}
# Check and assign dosage values
for dose_type in [
"threshold",
"light",
"common",
"strong",
"heavy",
]:
if dose_type in dose:
if isinstance(dose[dose_type], dict):
dosage_data[f"{dose_type}_lower"] = dose[
dose_type
]["min"]
dosage_data[f"{dose_type}_upper"] = dose[
dose_type
]["max"]
else:
dosage_data[f"{dose_type}_lower"] = dose[
dose_type
]
dosage_data[f"{dose_type}_upper"] = dose[
dose_type
]
# Check if Dosage already exists and is linked to the Drug
dosage, created = Dosage.objects.get_or_create(
**dosage_data
)
if created or dosage not in drug_obj.dosages.all():
drug_obj.dosages.add(dosage)
# Parsing timing information
timing = roa["duration"]
print("TIMING", timing)
# Check and assign timing values
if timing:
timing_data = {"entry": entry, "roa": roa_name}
for time_type in [
"onset",
"comeup",
"peak",
"offset",
"total",
]:
if (
time_type in timing
and timing[time_type] is not None
):
unit = timing[time_type].get("units", "hours")
# Handle case where timing value is a single integer
if isinstance(timing[time_type], int):
lower = timing[time_type]
upper = None
else:
lower = timing[time_type].get("min")
upper = timing[time_type].get("max")
if unit == "minutes" and lower is not None:
lower = lower / 60.0
if upper is not None:
upper = upper / 60.0
timing_data[f"{time_type}_lower"] = lower
timing_data[f"{time_type}_upper"] = upper
timing_data[
"unit"
] = "HOURS" # Store all times in hours
# Check if Timing already exists and is linked to the Drug
timing_obj, created = Timing.objects.get_or_create(
**timing_data
)
if created or timing_obj not in drug_obj.timings.all():
drug_obj.timings.add(timing_obj)
if "effects" in drug:
# Create or retrieve Effect object linked to the Entry
effect_obj, effect_created = Effect.objects.get_or_create(entry=entry)
for effect in drug["effects"]:
# Create or retrieve SEI object
sei_obj, sei_created = SEI.objects.get_or_create(
name=effect["name"],
url=effect.get(
"url", ""
), # Using .get() to handle missing urls
)
# Link SEI object to Effect if not already linked
if (
sei_created
or sei_obj not in effect_obj.subjective_effects.all()
):
effect_obj.subjective_effects.add(sei_obj)
# Link Effect object to Drug if not already linked
if effect_created or effect_obj not in drug_obj.effects.all():
drug_obj.effects.add(effect_obj)
print("SAVING DRUG", drug_obj)
drug_obj.save()
async def get_drugs_list(self):
"""
Get all drug names from PsychWiki
"""
body = {"query": "{substances(limit: %i) {name}}" % self.search_limit}
result = await self.call(
"?",
http_method="post",
data=body,
schema="GetDrugsList",
append_slash=False,
)
print("RESULT", result)
return result["data"]
async def get_all_data(self):
"""
Get all the drug data from PsychWiki (warning - intensive)
"""
body = {
"query": """
{
substances(limit: %i) {
name
url
featured
summary
addictionPotential
toxicity
crossTolerances
commonNames
class {
chemical
psychoactive
}
tolerance {
full
half
zero
}
roas {
name
dose {
units
threshold
heavy
common {
min
max
}
light {
min
max
}
strong {
min
max
}
}
duration {
afterglow {
min
max
units
}
comeup {
min
max
units
}
duration {
min
max
units
}
offset {
min
max
units
}
onset {
min
max
units
}
peak {
min
max
units
}
total {
min
max
units
}
}
bioavailability {
min
max
}
}
effects {
name
url
}
images {
thumb
image
}
uncertainInteractions {
name
}
unsafeInteractions {
name
}
dangerousInteractions {
name
}
}
}
"""
% self.search_limit
}
result = await self.call(
"?",
http_method="post",
data=body,
schema="GetDrugsList",
append_slash=False,
)
return result["data"]

@ -0,0 +1 @@
from core.lib.schemas import psychwiki_s # noqa

@ -0,0 +1,10 @@
from pydantic import BaseModel, Extra
class MyModel(BaseModel):
class Config:
extra = Extra.forbid
# class GetDrugsList(MyModel):
# ...

@ -0,0 +1,41 @@
# Generated by Django 4.2.9 on 2024-01-07 14:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_dosage_roa_timing_roa_alter_experiencedose_roa'),
]
operations = [
migrations.RenameField(
model_name='entry',
old_name='slug',
new_name='url',
),
migrations.RemoveField(
model_name='sei',
name='description',
),
migrations.RemoveField(
model_name='sei',
name='subtype',
),
migrations.RemoveField(
model_name='sei',
name='type',
),
migrations.AddField(
model_name='sei',
name='name',
field=models.CharField(default='DEFAULT', max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name='sei',
name='url',
field=models.CharField(blank=True, max_length=1024, null=True),
),
]

@ -0,0 +1,53 @@
# Generated by Django 4.2.9 on 2024-01-07 14:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_rename_slug_entry_url_remove_sei_description_and_more'),
]
operations = [
migrations.AlterField(
model_name='timing',
name='comeup_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='timing',
name='comeup_upper',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='timing',
name='onset_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='timing',
name='onset_upper',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='timing',
name='peak_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='timing',
name='peak_upper',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='timing',
name='total_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='timing',
name='total_upper',
field=models.FloatField(blank=True, null=True),
),
]

@ -0,0 +1,63 @@
# Generated by Django 4.2.9 on 2024-01-07 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_alter_timing_comeup_lower_alter_timing_comeup_upper_and_more'),
]
operations = [
migrations.AlterField(
model_name='dosage',
name='common_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='common_upper',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='heavy_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='heavy_upper',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='light_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='light_upper',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='strong_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='strong_upper',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='threshold_lower',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='dosage',
name='threshold_upper',
field=models.FloatField(blank=True, null=True),
),
]

@ -0,0 +1,23 @@
# Generated by Django 4.2.9 on 2024-01-07 14:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_alter_dosage_common_lower_alter_dosage_common_upper_and_more'),
]
operations = [
migrations.AlterField(
model_name='drug',
name='common_name',
field=models.CharField(blank=True, max_length=1024, null=True),
),
migrations.AlterField(
model_name='drug',
name='drug_class',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

@ -126,6 +126,9 @@ class Source(models.Model):
# Score, affects ordering
score = models.IntegerField(blank=True)
def __str__(self):
return f"{self.name} ({self.type})"
class Entry(models.Model):
"""
@ -136,7 +139,7 @@ class Entry(models.Model):
source = models.ForeignKey(Source, on_delete=models.CASCADE)
# Slug of the article on the Source
slug = models.CharField(max_length=1024, null=True, blank=True)
url = models.CharField(max_length=1024, null=True, blank=True)
# Authorship information, if present
author = models.CharField(max_length=255, null=True, blank=True)
@ -144,6 +147,9 @@ class Entry(models.Model):
# Extra information can be added
description = models.CharField(max_length=1024, null=True, blank=True)
def __str__(self):
return f"{self.source.name} - {self.url}"
class Dosage(models.Model):
"""
@ -160,24 +166,31 @@ class Dosage(models.Model):
unit = models.CharField(max_length=255, choices=DOSAGE_UNIT_CHOICES)
# I can no longer say I am sober, but it is slight
threshold_lower = models.FloatField()
threshold_upper = models.FloatField()
threshold_lower = models.FloatField(null=True, blank=True)
threshold_upper = models.FloatField(null=True, blank=True)
# Light
light_lower = models.FloatField()
light_upper = models.FloatField()
light_lower = models.FloatField(null=True, blank=True)
light_upper = models.FloatField(null=True, blank=True)
# Average dose for a user
common_lower = models.FloatField()
common_upper = models.FloatField()
common_lower = models.FloatField(null=True, blank=True)
common_upper = models.FloatField(null=True, blank=True)
# Strong intensity, many sober activities may become impossible
strong_lower = models.FloatField()
strong_upper = models.FloatField()
strong_lower = models.FloatField(null=True, blank=True)
strong_upper = models.FloatField(null=True, blank=True)
# Highest intensity
heavy_lower = models.FloatField()
heavy_upper = models.FloatField()
heavy_lower = models.FloatField(null=True, blank=True)
heavy_upper = models.FloatField(null=True, blank=True)
def __str__(self):
text = (
f"{self.threshold_lower} {self.light_lower} {self.common_lower} "
"{self.strong_lower} {self.heavy_lower}"
)
return f"{self.roa} {text} ({self.unit})"
class Timing(models.Model):
@ -197,24 +210,27 @@ class Timing(models.Model):
)
# It has just now begin, I can no longer say I am sober
onset_lower = models.FloatField()
onset_upper = models.FloatField()
onset_lower = models.FloatField(null=True, blank=True)
onset_upper = models.FloatField(null=True, blank=True)
# The intensity is accelerating
comeup_lower = models.FloatField()
comeup_upper = models.FloatField()
comeup_lower = models.FloatField(null=True, blank=True)
comeup_upper = models.FloatField(null=True, blank=True)
# The maximum intensity has been reached
# How long this state occurs
peak_lower = models.FloatField()
peak_upper = models.FloatField()
peak_lower = models.FloatField(null=True, blank=True)
peak_upper = models.FloatField(null=True, blank=True)
# How long it takes to get back to baseline
offset_lower = models.FloatField(null=True, blank=True)
offset_upper = models.FloatField(null=True, blank=True)
total_lower = models.FloatField()
total_upper = models.FloatField()
total_lower = models.FloatField(null=True, blank=True)
total_upper = models.FloatField(null=True, blank=True)
def __str__(self):
return f"{self.roa} {self.unit} {self.total_lower}-{self.total_upper}"
class SEI(models.Model):
@ -225,19 +241,24 @@ class SEI(models.Model):
"""
# PHYSICAL, COGNITIVE, etc
type = models.CharField(
max_length=255, choices=SEI_TYPE_CHOICES, default="PHYSICAL"
)
# type = models.CharField(
# max_length=255, choices=SEI_TYPE_CHOICES, default="PHYSICAL"
# )
subtype = models.CharField(
max_length=255,
choices=SEI_SUBTYPE_CHOICES,
)
# subtype = models.CharField(
# max_length=255,
# choices=SEI_SUBTYPE_CHOICES,
# )
# WIP: consider euphoric, depressant, relaxant
name = models.CharField(max_length=255)
url = models.CharField(max_length=1024, blank=True, null=True)
# Specify how
description = models.CharField(max_length=4096, blank=True, null=True)
# description = models.CharField(max_length=4096, blank=True, null=True)
def __str__(self):
return f"{self.name}"
class Effect(models.Model):
@ -252,6 +273,9 @@ class Effect(models.Model):
# List of subjective effects, since they would likely be from the same entry
subjective_effects = models.ManyToManyField(SEI)
def __str__(self):
return f"{self.entry} {self.subjective_effects}"
class Action(models.Model):
"""
@ -285,6 +309,9 @@ class Action(models.Model):
# Mechanism: Inhibition
# Affinity: Reversible (1)
def __str__(self):
return f"{self.site} {self.mechanism} {self.affinity}"
class ExperienceDose(models.Model):
# Seconds since the start of the experiment
@ -313,6 +340,9 @@ class ExperienceDose(models.Model):
# TODO: Sentiment analysis (use AI/ML)
# TODO: Time-based effects (use AI/ML)
def __str__(self):
return f"{self.form} {self.dose} {self.unit} {self.roa}"
class Experience(models.Model):
"""
@ -339,6 +369,9 @@ class Experience(models.Model):
# Description of the experience
text = models.TextField()
def __str__(self):
return f"Experience ({len(self.text)} chars)"
class Drug(models.Model):
"""
@ -349,10 +382,10 @@ class Drug(models.Model):
name = models.CharField(max_length=255, unique=True)
# Psychedelic, Sedative, Stimulant
drug_class = models.CharField(max_length=255)
drug_class = models.CharField(max_length=255, blank=True, null=True)
# LSD
common_name = models.CharField(max_length=1024, unique=True)
common_name = models.CharField(max_length=1024, blank=True, null=True)
# Factsheets, posts
links = models.ManyToManyField(Entry, blank=True)
@ -372,6 +405,9 @@ class Drug(models.Model):
# Experiences, what do people experience?
experiences = models.ManyToManyField(Experience, blank=True)
def __str__(self):
return f"{self.name} ({self.common_name})"
# class Perms(models.Model):
# class Meta:

@ -28,7 +28,7 @@
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.drug_class }}</td>
<td>{{ item.common_name }}s</td>
<td>{{ item.common_name }}</td>
<td>{{ item.links.count }}</td>
<td>{{ item.dosages.count }}</td>
<td>{{ item.timings.count }}</td>

@ -1,9 +1,12 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import render
from django.urls import reverse
from rest_framework.views import APIView
from core.clients.sources.psychwiki import PsychWikiClient
from core.forms import DrugForm
from core.models import Drug
from core.views.helpers import synchronize_async_helper
from mxs.restrictions import StaffMemberRequiredMixin
from mxs.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
@ -19,6 +22,19 @@ class DrugList(LoginRequiredMixin, StaffMemberRequiredMixin, ObjectList):
submit_url_name = "drug_create"
delete_all_url_name = "drug_clear"
def get_context_data(self):
context = super().get_context_data()
self.extra_buttons = [
{
"url": reverse("drug_pull_merge"),
"action": "refresh",
"method": "post",
"label": "Update database from sources",
"icon": "fa-solid fa-refresh",
},
]
return context
class DrugCreate(LoginRequiredMixin, StaffMemberRequiredMixin, ObjectCreate):
model = Drug
@ -50,3 +66,18 @@ class DrugClear(LoginRequiredMixin, StaffMemberRequiredMixin, APIView):
response = render(request, template_name, context)
response["HX-Trigger"] = "drugEvent"
return response
class DrugPullMerge(LoginRequiredMixin, StaffMemberRequiredMixin, APIView):
def post(self, request):
template_name = "mixins/partials/notify.html"
# Do something
run = synchronize_async_helper(PsychWikiClient())
result = synchronize_async_helper(run.update_drugs())
context = {
"message": f"Drugs fetched: {result}",
"class": "success",
}
response = render(request, template_name, context)
response["HX-Trigger"] = "drugEvent"
return response

@ -0,0 +1,18 @@
import asyncio
def synchronize_async_helper(to_await):
async_response = []
async def run_and_capture_result():
r = await to_await
async_response.append(r)
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
coroutine = run_and_capture_result()
loop.run_until_complete(coroutine)
return async_response[0]

@ -21,8 +21,10 @@ django-otp-yubikey
phonenumbers
qrcode
pydantic
# glom
glom
git+https://git.zm.is/XF/django-crud-mixins
aiohttp[speedups]
# pyroscope-io
# For caching
redis

Loading…
Cancel
Save