From 0488a3e0b2a0ee658cb331830f2a525e597fff68 Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Mon, 15 Jan 2024 15:29:33 +0000 Subject: [PATCH] Implement drug refresh and view --- app/urls.py | 5 + core/clients/__init__.py | 0 core/clients/base.py | 205 ++++++++++++ core/clients/graphql.py | 26 ++ core/clients/sources/psychwiki.py | 297 ++++++++++++++++++ core/lib/schemas/__init__.py | 1 + core/lib/schemas/psychwiki_s.py | 10 + ...try_url_remove_sei_description_and_more.py | 41 +++ ...ower_alter_timing_comeup_upper_and_more.py | 53 ++++ ...ower_alter_dosage_common_upper_and_more.py | 63 ++++ ..._drug_common_name_alter_drug_drug_class.py | 23 ++ core/models.py | 94 ++++-- core/templates/partials/drug-list.html | 2 +- core/views/drugs.py | 31 ++ core/views/helpers.py | 18 ++ requirements.txt | 4 +- 16 files changed, 842 insertions(+), 31 deletions(-) create mode 100644 core/clients/__init__.py create mode 100644 core/clients/base.py create mode 100644 core/clients/graphql.py create mode 100644 core/clients/sources/psychwiki.py create mode 100644 core/lib/schemas/__init__.py create mode 100644 core/lib/schemas/psychwiki_s.py create mode 100644 core/migrations/0006_rename_slug_entry_url_remove_sei_description_and_more.py create mode 100644 core/migrations/0007_alter_timing_comeup_lower_alter_timing_comeup_upper_and_more.py create mode 100644 core/migrations/0008_alter_dosage_common_lower_alter_dosage_common_upper_and_more.py create mode 100644 core/migrations/0009_alter_drug_common_name_alter_drug_drug_class.py create mode 100644 core/views/helpers.py diff --git a/app/urls.py b/app/urls.py index 9ec6016..dcf1237 100644 --- a/app/urls.py +++ b/app/urls.py @@ -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) diff --git a/core/clients/__init__.py b/core/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/clients/base.py b/core/clients/base.py new file mode 100644 index 0000000..f5e1edb --- /dev/null +++ b/core/clients/base.py @@ -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 diff --git a/core/clients/graphql.py b/core/clients/graphql.py new file mode 100644 index 0000000..9a2584b --- /dev/null +++ b/core/clients/graphql.py @@ -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 diff --git a/core/clients/sources/psychwiki.py b/core/clients/sources/psychwiki.py new file mode 100644 index 0000000..2be84cd --- /dev/null +++ b/core/clients/sources/psychwiki.py @@ -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"] diff --git a/core/lib/schemas/__init__.py b/core/lib/schemas/__init__.py new file mode 100644 index 0000000..b0dc6be --- /dev/null +++ b/core/lib/schemas/__init__.py @@ -0,0 +1 @@ +from core.lib.schemas import psychwiki_s # noqa diff --git a/core/lib/schemas/psychwiki_s.py b/core/lib/schemas/psychwiki_s.py new file mode 100644 index 0000000..86c8c7d --- /dev/null +++ b/core/lib/schemas/psychwiki_s.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Extra + + +class MyModel(BaseModel): + class Config: + extra = Extra.forbid + + +# class GetDrugsList(MyModel): +# ... diff --git a/core/migrations/0006_rename_slug_entry_url_remove_sei_description_and_more.py b/core/migrations/0006_rename_slug_entry_url_remove_sei_description_and_more.py new file mode 100644 index 0000000..ef7be38 --- /dev/null +++ b/core/migrations/0006_rename_slug_entry_url_remove_sei_description_and_more.py @@ -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), + ), + ] diff --git a/core/migrations/0007_alter_timing_comeup_lower_alter_timing_comeup_upper_and_more.py b/core/migrations/0007_alter_timing_comeup_lower_alter_timing_comeup_upper_and_more.py new file mode 100644 index 0000000..d4497fc --- /dev/null +++ b/core/migrations/0007_alter_timing_comeup_lower_alter_timing_comeup_upper_and_more.py @@ -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), + ), + ] diff --git a/core/migrations/0008_alter_dosage_common_lower_alter_dosage_common_upper_and_more.py b/core/migrations/0008_alter_dosage_common_lower_alter_dosage_common_upper_and_more.py new file mode 100644 index 0000000..a856559 --- /dev/null +++ b/core/migrations/0008_alter_dosage_common_lower_alter_dosage_common_upper_and_more.py @@ -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), + ), + ] diff --git a/core/migrations/0009_alter_drug_common_name_alter_drug_drug_class.py b/core/migrations/0009_alter_drug_common_name_alter_drug_drug_class.py new file mode 100644 index 0000000..43f203d --- /dev/null +++ b/core/migrations/0009_alter_drug_common_name_alter_drug_drug_class.py @@ -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), + ), + ] diff --git a/core/models.py b/core/models.py index f38681e..1f60514 100644 --- a/core/models.py +++ b/core/models.py @@ -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: diff --git a/core/templates/partials/drug-list.html b/core/templates/partials/drug-list.html index ce8e367..c3258e0 100644 --- a/core/templates/partials/drug-list.html +++ b/core/templates/partials/drug-list.html @@ -28,7 +28,7 @@ {{ item.id }} {{ item.name }} {{ item.drug_class }} - {{ item.common_name }}s + {{ item.common_name }} {{ item.links.count }} {{ item.dosages.count }} {{ item.timings.count }} diff --git a/core/views/drugs.py b/core/views/drugs.py index bcce03b..c67a332 100644 --- a/core/views/drugs.py +++ b/core/views/drugs.py @@ -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 diff --git a/core/views/helpers.py b/core/views/helpers.py new file mode 100644 index 0000000..b25154f --- /dev/null +++ b/core/views/helpers.py @@ -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] diff --git a/requirements.txt b/requirements.txt index 2549bfb..7cb95b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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