From 9ddbc2624c5e62e15796d334f9e84af480c64455 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Nov 2025 12:00:02 -0500 Subject: [PATCH 1/5] ISC --- backend/proteins/util/maintain.py | 2 +- backend/proteins/views/protein.py | 12 ++++++------ pyproject.toml | 4 +++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/proteins/util/maintain.py b/backend/proteins/util/maintain.py index d7cf694b..6a2d979f 100644 --- a/backend/proteins/util/maintain.py +++ b/backend/proteins/util/maintain.py @@ -77,7 +77,7 @@ def validate_node(node: "Lineage") -> list[str]: if ms: errors.append( f"{node.parent.protein} + {node.mutation} does not match " - + f"the current {node.protein} sequence (Δ: {ms})" + f"the current {node.protein} sequence (Δ: {ms})" ) except Mutation.SequenceMismatch as e: errors.append(str(e).replace("parent", node.parent.protein.name)) diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index ce959f00..ba14a656 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -70,10 +70,10 @@ def check_switch_type(object, request): actual = object.get_switch_type_display().lower() msg = ( "Warning: " - + "Based on the number of states and transitions currently assigned " - + f"to this protein, it appears to be a {disp} protein; " - + f"however, it has been assigned a switching type of {actual}. " - + "Please confirm that the switch type, states, and transitions are correct." + "Based on the number of states and transitions currently assigned " + f"to this protein, it appears to be a {disp} protein; " + f"however, it has been assigned a switching type of {actual}. " + "Please confirm that the switch type, states, and transitions are correct." ) messages.add_message(request, messages.WARNING, msg) @@ -311,8 +311,8 @@ def form_valid(self, form: ProteinForm): prot = Protein.objects.get(seq__iexact=str(seq)) msg = mark_safe( f'{prot.name} already has the sequence that' - + " would be generated by this parent & mutation" + f'underline;">{prot.name} already has the sequence that' + " would be generated by this parent & mutation" ) lform.add_error(None, msg) context |= {"states": states, "lineage": lineage} diff --git a/pyproject.toml b/pyproject.toml index 149aa200..9abd3245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,12 +126,14 @@ select = [ "UP", # pyupgrade "YTT", # flake8-2020 "B", # flake8-bugbear + "ISC", # string concatenation "C4", # flake8-comprehensions "DJ", # flake8-django "RUF", # Ruff-specific rules "LOG", # flake8-logging "G", # flake8-logging-format - "TC", # flake8-type-checking + "TC", # flake8-type-checking + # "SIM", # Common simplification rules ] ignore = [ "B905", # `zip()` without an explicit `strict=` parameter From 95e104cd9ba96d4d9e5440deced9a236be7fb52a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Nov 2025 12:01:37 -0500 Subject: [PATCH 2/5] SIM --- backend/config/api_router.py | 5 +- backend/config/settings/base.py | 21 ++- backend/fpseq/mutations.py | 11 +- backend/proteins/admin.py | 5 +- backend/proteins/extrest/entrez.py | 2 +- backend/proteins/forms/forms.py | 11 +- backend/proteins/forms/microscope.py | 23 +-- backend/proteins/models/fluorophore.py | 13 +- backend/proteins/models/microscope.py | 5 +- backend/proteins/models/spectrum.py | 23 ++- backend/proteins/util/helpers.py | 14 +- backend/proteins/util/importers.py | 11 +- backend/proteins/util/maintain.py | 5 +- backend/proteins/views/ajax.py | 10 +- backend/proteins/views/autocomplete.py | 5 +- backend/proteins/views/microscope.py | 5 +- backend/proteins/views/protein.py | 218 ++++++++++++------------- backend/tests_e2e/conftest.py | 11 +- backend/tests_e2e/snapshot_plugin.py | 5 +- pyproject.toml | 2 +- 20 files changed, 173 insertions(+), 232 deletions(-) diff --git a/backend/config/api_router.py b/backend/config/api_router.py index 8a264481..01337c2f 100644 --- a/backend/config/api_router.py +++ b/backend/config/api_router.py @@ -4,10 +4,7 @@ import proteins.api.urls from fpbase.users.api.views import UserViewSet -if settings.DEBUG: - router = DefaultRouter() -else: - router = SimpleRouter() +router = DefaultRouter() if settings.DEBUG else SimpleRouter() router.register("users", UserViewSet) diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index f347b7ea..4b474420 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -464,17 +464,16 @@ def add_sentry_context(logger, method_name, event_dict): using the ID to find the full exception context in Sentry. """ # Check if sentry_event_id was explicitly passed in extra dict - if "sentry_event_id" not in event_dict: - # Try to get it from Sentry SDK's last_event_id() - # This works if Django/Sentry integration auto-captured an exception - if event_dict.get("exc_info") or "exception" in event_dict: - try: - import sentry_sdk - - if event_id := sentry_sdk.last_event_id(): - event_dict["sentry_event_id"] = event_id - except (ImportError, AttributeError, Exception): - pass # Sentry not available + # Try to get it from Sentry SDK's last_event_id() + # This works if Django/Sentry integration auto-captured an exception + if "sentry_event_id" not in event_dict and (event_dict.get("exc_info") or "exception" in event_dict): + try: + import sentry_sdk + + if event_id := sentry_sdk.last_event_id(): + event_dict["sentry_event_id"] = event_id + except (ImportError, AttributeError, Exception): + pass # Sentry not available return event_dict diff --git a/backend/fpseq/mutations.py b/backend/fpseq/mutations.py index cefe3361..99617ed3 100644 --- a/backend/fpseq/mutations.py +++ b/backend/fpseq/mutations.py @@ -171,9 +171,7 @@ def __repr__(self): return f"" def __eq__(self, other): - if str(self) == str(other): - return True - return False + return str(self) == str(other) def __hash__(self): return hash(str(self)) @@ -230,7 +228,7 @@ def _assert_position_consistency(self, seq, idx0=1): ) if self.stop_idx and self.stop_char: stoppos = self.stop_idx - idx0 - if not seq[stoppos] == self.stop_char: + if seq[stoppos] != self.stop_char: beg = stoppos - 3 beg2 = stoppos + 1 end = stoppos + 4 @@ -647,10 +645,7 @@ def rand_mut(seq): elif operation in ("ins", "delins"): new_chars = "".join(choices(AAs, k=randint(1, 6))) if operation in ("del", "ins", "delins"): - if operation == "ins": - stop_idx = start_idx + 1 - else: - stop_idx = start_idx + randint(1, 6) + stop_idx = start_idx + 1 if operation == "ins" else start_idx + randint(1, 6) while stop_idx > len(seq) - 2: stop_idx = start_idx + randint(0, 6) stop_char = seq[stop_idx - 1] diff --git a/backend/proteins/admin.py b/backend/proteins/admin.py index bd68f9cb..3c3730e7 100644 --- a/backend/proteins/admin.py +++ b/backend/proteins/admin.py @@ -284,10 +284,7 @@ def owner(self, obj): owner = obj.owner # FluorState is a base class - resolve to the actual subclass admin if isinstance(owner, FluorState): - if owner.entity_type == FluorState.EntityTypes.PROTEIN: - model_name = "state" - else: - model_name = "dyestate" + model_name = "state" if owner.entity_type == FluorState.EntityTypes.PROTEIN else "dyestate" else: model_name = owner._meta.model.__name__.lower() diff --git a/backend/proteins/extrest/entrez.py b/backend/proteins/extrest/entrez.py index 6cc515d3..02220a48 100644 --- a/backend/proteins/extrest/entrez.py +++ b/backend/proteins/extrest/entrez.py @@ -103,7 +103,7 @@ def _get_pmid_info(pmid: str) -> DoiInfo | None: def _merge_info(dict1: MutableMapping, dict2: MutableMapping, exclude=()) -> MutableMapping: """existings values in dict2 will overwrite dict1""" - for key in dict1.keys(): + for key in dict1: if key in dict2 and dict2[key] and key not in exclude: dict1[key] = dict2[key] return dict1 diff --git a/backend/proteins/forms/forms.py b/backend/proteins/forms/forms.py index 435f291c..bc3d7081 100644 --- a/backend/proteins/forms/forms.py +++ b/backend/proteins/forms/forms.py @@ -439,10 +439,9 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) instance = getattr(self, "instance", None) - if instance and instance.pk: - if instance.protein.seq_validated or instance.children.exists(): - self.fields["mutation"].widget.attrs["readonly"] = True - self.fields["parent"].widget.attrs["readonly"] = True + if instance and instance.pk and (instance.protein.seq_validated or instance.children.exists()): + self.fields["mutation"].widget.attrs["readonly"] = True + self.fields["parent"].widget.attrs["readonly"] = True self.helper = FormHelper() self.helper.form_tag = False @@ -510,7 +509,7 @@ class Meta: ) def __init__(self, *args, **kwargs): - instance = kwargs.get("instance", None) + instance = kwargs.get("instance") if instance: kwargs.update(initial={"reference_doi": instance.reference.doi}) super().__init__(*args, **kwargs) @@ -605,7 +604,7 @@ class Meta: ) def __init__(self, *args, **kwargs): - instance = kwargs.get("instance", None) + instance = kwargs.get("instance") if instance: kwargs.update(initial={"reference_doi": instance.reference.doi}) super().__init__(*args, **kwargs) diff --git a/backend/proteins/forms/microscope.py b/backend/proteins/forms/microscope.py index 998b4f89..c2713a31 100644 --- a/backend/proteins/forms/microscope.py +++ b/backend/proteins/forms/microscope.py @@ -118,10 +118,7 @@ class Meta: } def create_oc(self, name, filters): - if len(filters) == 4: - bs_em_reflect = not bool(filters.pop()) - else: - bs_em_reflect = False + bs_em_reflect = not bool(filters.pop()) if len(filters) == 4 else False oc = OpticalConfig.objects.create(name=name, owner=self.user, microscope=self.instance) _paths = [FilterPlacement.EX, FilterPlacement.BS, FilterPlacement.EM] @@ -160,9 +157,8 @@ def save(self, commit=True): for row in self.cleaned_data["optical_configs"]: newoc = self.create_oc(row[0], row[1:]) if newoc: - if self.cleaned_data["light_source"].count() == 1: - if not newoc.laser: - newoc.light = self.cleaned_data["light_source"].first() + if self.cleaned_data["light_source"].count() == 1 and not newoc.laser: + newoc.light = self.cleaned_data["light_source"].first() if self.cleaned_data["detector"].count() == 1: newoc.camera = self.cleaned_data["detector"].first() newoc.save() @@ -337,7 +333,7 @@ class OpticalConfigForm(forms.ModelForm): ) def __init__(self, *args, **kwargs): - instance = kwargs.get("instance", None) + instance = kwargs.get("instance") if instance: kwargs.update( initial={ @@ -367,12 +363,11 @@ def save(self, commit=True): if path == "bs": reflects = self.cleaned_data["invert_bs"] oc.add_filter(filt, path, reflects) - if self.initial: - if self.cleaned_data["invert_bs"] != self.initial.get("invert_bs"): - for filt in self.cleaned_data["bs_filters"]: - for fp in oc.filterplacement_set.filter(filter=filt, path=FilterPlacement.BS): - fp.reflects = self.cleaned_data["invert_bs"] - fp.save() + if self.initial and self.cleaned_data["invert_bs"] != self.initial.get("invert_bs"): + for filt in self.cleaned_data["bs_filters"]: + for fp in oc.filterplacement_set.filter(filter=filt, path=FilterPlacement.BS): + fp.reflects = self.cleaned_data["invert_bs"] + fp.save() oc.save() return oc diff --git a/backend/proteins/models/fluorophore.py b/backend/proteins/models/fluorophore.py index 03eca750..443a57c2 100644 --- a/backend/proteins/models/fluorophore.py +++ b/backend/proteins/models/fluorophore.py @@ -145,11 +145,10 @@ def rebuild_attributes(self) -> None: # Handle pinned fields first (admin overrides) pinned_fields: set[str] = set() for field, mid in pinned_source_map.items(): - if field in measurable_fields and (m := pinned_by_id.get(mid)): - if (val := getattr(m, field)) is not None: - new_values[field] = val - new_source_map[field] = m.id - pinned_fields.add(field) + if field in measurable_fields and (m := pinned_by_id.get(mid)) and (val := getattr(m, field)) is not None: + new_values[field] = val + new_source_map[field] = m.id + pinned_fields.add(field) # Sort by primary_reference measurements = sorted( @@ -231,9 +230,7 @@ def stokes(self) -> float | None: return None def has_spectra(self) -> bool: - if any([self.ex_spectrum, self.em_spectrum]): - return True - return False + return bool(any([self.ex_spectrum, self.em_spectrum])) def ex_band(self, height=0.7) -> tuple[float, float] | None: if (spect := self.ex_spectrum) is not None: diff --git a/backend/proteins/models/microscope.py b/backend/proteins/models/microscope.py index 66692cb7..53cbf0ef 100644 --- a/backend/proteins/models/microscope.py +++ b/backend/proteins/models/microscope.py @@ -404,10 +404,7 @@ def _assign_filt(_f, i, iexact=False): oc.laser = int(_f) oc.save() except ValueError: - if iexact: - filt = Filter.objects.get(part__iexact=_f) - else: - filt = Filter.objects.get(name__icontains=_f) + filt = Filter.objects.get(part__iexact=_f) if iexact else Filter.objects.get(name__icontains=_f) fp = FilterPlacement( filter=filt, config=oc, diff --git a/backend/proteins/models/spectrum.py b/backend/proteins/models/spectrum.py index 6d3a1c82..46dce771 100644 --- a/backend/proteins/models/spectrum.py +++ b/backend/proteins/models/spectrum.py @@ -464,17 +464,16 @@ def clean(self): self.subtype = self.QE if self.category == self.LIGHT: self.subtype = self.PD - if self.category in self.category_subtypes: - if self.subtype not in self.category_subtypes[self.category]: - errors.update( - { - "subtype": "{} spectrum subtype must be{} {}".format( - self.get_category_display(), - "" if len(self.category_subtypes[self.category]) > 1 else " one of:", - " ".join(self.category_subtypes[self.category]), - ) - } - ) + if self.category in self.category_subtypes and self.subtype not in self.category_subtypes[self.category]: + errors.update( + { + "subtype": "{} spectrum subtype must be{} {}".format( + self.get_category_display(), + "" if len(self.category_subtypes[self.category]) > 1 else " one of:", + " ".join(self.category_subtypes[self.category]), + ) + } + ) if errors: raise ValidationError(errors) @@ -590,7 +589,7 @@ def d3dict(self) -> D3Dict: "category": self.category, "type": self.subtype if self.subtype != self.ABS else self.EX, "color": self.color(), - "area": False if self.subtype in (self.LP, self.BS) else True, + "area": self.subtype not in (self.LP, self.BS), "url": self.owner.get_absolute_url(), "classed": f"category-{self.category} subtype-{self.subtype}", } diff --git a/backend/proteins/util/helpers.py b/backend/proteins/util/helpers.py index 7ea10737..f7e94729 100644 --- a/backend/proteins/util/helpers.py +++ b/backend/proteins/util/helpers.py @@ -117,20 +117,14 @@ def getmut(protname2, protname1=None, ref=None): from fpseq.mutations import get_mutations a = getprot(protname2) - if protname1: - b = getprot(protname1) - else: - b = a.lineage.parent.protein + b = getprot(protname1) if protname1 else a.lineage.parent.protein ref = getprot(ref).seq if ref else None return get_mutations(b.seq, a.seq, ref) def showalign(protname2, protname1=None): a = getprot(protname2) - if protname1: - b = getprot(protname1) - else: - b = a.lineage.parent.protein + b = getprot(protname1) if protname1 else a.lineage.parent.protein print(b.seq.align_to(a.seq)) @@ -165,7 +159,7 @@ def wave_to_hex(wavelength, gamma=1): return "#000" wavelength = float(wavelength) - if 520 <= wavelength: + if wavelength >= 520: wavelength += 40 if wavelength < 380: @@ -458,7 +452,7 @@ def spectra_fig( if not xlim: xlim = (min([s.min_wave for s in spectra]), max([s.max_wave for s in spectra])) for spec in spectra: - color = spec.color() if not colr else colr + color = colr if colr else spec.color() if fill: alpha = 0.5 if not alph else float(alph) ax.fill_between(*list(zip(*spec.data)), color=color, alpha=alpha, url="http://google.com=", **kwargs) diff --git a/backend/proteins/util/importers.py b/backend/proteins/util/importers.py index dcfec492..9be10b5b 100644 --- a/backend/proteins/util/importers.py +++ b/backend/proteins/util/importers.py @@ -93,12 +93,11 @@ def handle_starttag(self, tag, attrs): for k, v in attrs: if k == "class" and v == "file": self.ready = 1 - elif self.ready == 1: - if tag == "a" and len(attrs): - for k, v in attrs: - if k == "href" and "download?token" in v: - self.url = v - self.ready = 2 + elif self.ready == 1 and tag == "a" and len(attrs): + for k, v in attrs: + if k == "href" and "download?token" in v: + self.url = v + self.ready = 2 part = part.replace("/", "-") chroma_url = "https://www.chroma.com/products/parts/" diff --git a/backend/proteins/util/maintain.py b/backend/proteins/util/maintain.py index 6a2d979f..0a4cae3f 100644 --- a/backend/proteins/util/maintain.py +++ b/backend/proteins/util/maintain.py @@ -35,10 +35,7 @@ def suggested_switch_type(protein: Protein) -> str | None: return protein.SwitchingChoices.BASIC # 2 or more states... n_transitions = protein.transitions.count() - if hasattr(protein, "ndark"): - darkstates = protein.ndark - else: - darkstates = protein.states.filter(is_dark=True).count() + darkstates = protein.ndark if hasattr(protein, "ndark") else protein.states.filter(is_dark=True).count() if not n_transitions: return str(protein.SwitchingChoices.OTHER) elif nstates == 2: diff --git a/backend/proteins/views/ajax.py b/backend/proteins/views/ajax.py index 0fdc7776..c54aba2a 100644 --- a/backend/proteins/views/ajax.py +++ b/backend/proteins/views/ajax.py @@ -49,10 +49,8 @@ def update_comparison(request): if request.POST.get("operation") == "add": current.add(request.POST.get("object")) elif request.POST.get("operation") == "remove": - try: + with contextlib.suppress(KeyError): current.remove(request.POST.get("object")) - except KeyError: - pass elif request.POST.get("operation") == "clear": current.clear() request.session["comparison"] = list(current) @@ -155,10 +153,8 @@ def similar_spectrum_owners(request): similars_optimized = [] for s in similars: - if isinstance(s, FluorState): - key = s.id # For Fluorophores, look up by ID only - else: - key = (s.__class__.__name__, s.id) + # For Fluorophores, look up by ID only + key = s.id if isinstance(s, FluorState) else (s.__class__.__name__, s.id) similars_optimized.append(similars_dict.get(key, s)) data = { diff --git a/backend/proteins/views/autocomplete.py b/backend/proteins/views/autocomplete.py index 59ed1c00..d2725a0d 100644 --- a/backend/proteins/views/autocomplete.py +++ b/backend/proteins/views/autocomplete.py @@ -18,10 +18,7 @@ def get_results(self, context): ] def get_queryset(self): - if self.request.GET.get("type", "") == "spectra": - qs = Protein.objects.with_spectra() - else: - qs = Protein.objects.all() + qs = Protein.objects.with_spectra() if self.request.GET.get("type", "") == "spectra" else Protein.objects.all() if self.q: qs = qs.filter(name__icontains=self.q) return qs diff --git a/backend/proteins/views/microscope.py b/backend/proteins/views/microscope.py index 8d6ec2d7..d529ace5 100644 --- a/backend/proteins/views/microscope.py +++ b/backend/proteins/views/microscope.py @@ -1,3 +1,4 @@ +import contextlib import json from collections import defaultdict from urllib.parse import quote @@ -279,15 +280,13 @@ def form_valid(self, form): "click the icon below the graph.", ) if not self.request.user.is_staff: - try: + with contextlib.suppress(Exception): mail_admins( "Microscope Created", f"User: {self.request.user.username}\nMicroscope: {self.object}" f"\n{self.request.build_absolute_uri(self.object.get_absolute_url())}", fail_silently=True, ) - except Exception: - pass return response def get_context_data(self, **kwargs): diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index ba14a656..84082341 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -139,10 +139,7 @@ def get_country_code(request) -> str: """Get country code from IP address using cached API lookup.""" x_forwarded_for = request.headers.get("x-forwarded-for") - if x_forwarded_for: - ip = x_forwarded_for.split(",")[0].strip() - else: - ip = request.META.get("REMOTE_ADDR") + ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else request.META.get("REMOTE_ADDR") try: if not (masked_ip := _mask_ip_for_caching(ip)): @@ -318,70 +315,69 @@ def form_valid(self, form: ProteinForm): context |= {"states": states, "lineage": lineage} return self.render_to_response(context) - with transaction.atomic(): - with reversion.create_revision(): - self.object = form.save() - self.object.primary_reference = ( - Reference.objects.get_or_create(doi=doi.lower())[0] - if (doi := form.cleaned_data.get("reference_doi")) - else None - ) + with transaction.atomic(), reversion.create_revision(): + self.object = form.save() + self.object.primary_reference = ( + Reference.objects.get_or_create(doi=doi.lower())[0] + if (doi := form.cleaned_data.get("reference_doi")) + else None + ) - states.instance = self.object - saved_states = states.save(commit=False) - for s in saved_states: - if not s.created_by: - s.created_by = self.request.user - s.updated_by = self.request.user - s.save() - for s in states.deleted_objects: - if self.object.default_state == s: - self.object.default_state = None - s.delete() - - lineage.instance = self.object - for lin in lineage.save(commit=False): - # if the form has been cleared and there are no children, - # let's clean up a little - if not (lin.mutation and lin.parent) and not lin.children.exists(): - lin.delete() - else: - if not lin.created_by: - lin.created_by = self.request.user - lin.updated_by = self.request.user - lin.reference = self.object.primary_reference - lin.save() - for lin in lineage.deleted_objects: + states.instance = self.object + saved_states = states.save(commit=False) + for s in saved_states: + if not s.created_by: + s.created_by = self.request.user + s.updated_by = self.request.user + s.save() + for s in states.deleted_objects: + if self.object.default_state == s: + self.object.default_state = None + s.delete() + + lineage.instance = self.object + for lin in lineage.save(commit=False): + # if the form has been cleared and there are no children, + # let's clean up a little + if not (lin.mutation and lin.parent) and not lin.children.exists(): lin.delete() + else: + if not lin.created_by: + lin.created_by = self.request.user + lin.updated_by = self.request.user + lin.reference = self.object.primary_reference + lin.save() + for lin in lineage.deleted_objects: + lin.delete() + + if hasattr(self.object, "lineage"): + if not self.object.seq: + seq = self.object.lineage.parent.protein.seq.mutate(self.object.lineage.mutation) + self.object.seq = str(seq) + if not self.object.parent_organism: + self.object.parent_organism = self.object.lineage.root_node.protein.parent_organism + + comment = f"{self.object} {self.get_form_type()} form." + chg_string = "\n".join(get_form_changes(form, states, lineage)) + + if not self.request.user.is_staff: + self.object.status = "pending" + msg = f"User: {self.request.user.username}\n" + f"Protein: {self.object}\n\n{chg_string}\n\n" + f"{self.request.build_absolute_uri(self.object.get_absolute_url())}" + mail_managers(comment, msg, fail_silently=True) + # else: + # self.object.status = 'approved' + + self.object.save() + reversion.set_user(self.request.user) + reversion.set_comment(chg_string) + try: + uncache_protein_page(self.object.slug, self.request) + except Exception as e: + logger.error("failed to uncache protein: %s", e) - if hasattr(self.object, "lineage"): - if not self.object.seq: - seq = self.object.lineage.parent.protein.seq.mutate(self.object.lineage.mutation) - self.object.seq = str(seq) - if not self.object.parent_organism: - self.object.parent_organism = self.object.lineage.root_node.protein.parent_organism - - comment = f"{self.object} {self.get_form_type()} form." - chg_string = "\n".join(get_form_changes(form, states, lineage)) - - if not self.request.user.is_staff: - self.object.status = "pending" - msg = f"User: {self.request.user.username}\n" - f"Protein: {self.object}\n\n{chg_string}\n\n" - f"{self.request.build_absolute_uri(self.object.get_absolute_url())}" - mail_managers(comment, msg, fail_silently=True) - # else: - # self.object.status = 'approved' - - self.object.save() - reversion.set_user(self.request.user) - reversion.set_comment(chg_string) - try: - uncache_protein_page(self.object.slug, self.request) - except Exception as e: - logger.error("failed to uncache protein: %s", e) - - check_switch_type(self.object, self.request) + check_switch_type(self.object, self.request) return HttpResponseRedirect(self.get_success_url()) @@ -756,26 +752,25 @@ def update_transitions(request, slug=None): if not formset.is_valid(): return render(request, template_name, {"transition_form": formset}, status=422) - with transaction.atomic(): - with reversion.create_revision(): - formset.save() - chg_string = "\n".join(get_form_changes(formset)) + with transaction.atomic(), reversion.create_revision(): + formset.save() + chg_string = "\n".join(get_form_changes(formset)) - if not request.user.is_staff: - obj.status = "pending" - mail_managers( - "Transition updated", - f"User: {request.user.username}\nProtein: {obj}\n\n{chg_string}", - fail_silently=True, - ) - obj.save() - reversion.set_user(request.user) - reversion.set_comment(chg_string) - try: - uncache_protein_page(slug, request) - except Exception as e: - logger.error("failed to uncache protein: %s", e) - check_switch_type(obj, request) + if not request.user.is_staff: + obj.status = "pending" + mail_managers( + "Transition updated", + f"User: {request.user.username}\nProtein: {obj}\n\n{chg_string}", + fail_silently=True, + ) + obj.save() + reversion.set_user(request.user) + reversion.set_comment(chg_string) + try: + uncache_protein_page(slug, request) + except Exception as e: + logger.error("failed to uncache protein: %s", e) + check_switch_type(obj, request) return HttpResponse(status=200) else: formset = StateTransitionFormSet(instance=obj) @@ -796,37 +791,36 @@ def protein_bleach_formsets(request, slug): if not formset.is_valid(): return render(request, template_name, {"formset": formset, "protein": protein}) - with transaction.atomic(): - with reversion.create_revision(): - saved = formset.save(commit=False) - for s in saved: - if not s.created_by: - s.created_by = request.user - s.updated_by = request.user - s.save() - for s in formset.deleted_objects: - s.delete() + with transaction.atomic(), reversion.create_revision(): + saved = formset.save(commit=False) + for s in saved: + if not s.created_by: + s.created_by = request.user + s.updated_by = request.user + s.save() + for s in formset.deleted_objects: + s.delete() - chg_string = "\n".join(get_form_changes(formset)) + chg_string = "\n".join(get_form_changes(formset)) - if not request.user.is_staff: - protein.status = "pending" - mail_managers( - "BleachMeasurement Added", - f"User: {request.user.username}\nProtein: {protein}\n{chg_string}\n\n" - f"{request.build_absolute_uri(protein.get_absolute_url())}", - fail_silently=True, - ) - # else: - # protein.status = 'approved' + if not request.user.is_staff: + protein.status = "pending" + mail_managers( + "BleachMeasurement Added", + f"User: {request.user.username}\nProtein: {protein}\n{chg_string}\n\n" + f"{request.build_absolute_uri(protein.get_absolute_url())}", + fail_silently=True, + ) + # else: + # protein.status = 'approved' - protein.save() - reversion.set_user(request.user) - reversion.set_comment(chg_string) - try: - uncache_protein_page(slug, request) - except Exception as e: - logger.error("failed to uncache protein: %s", e) + protein.save() + reversion.set_user(request.user) + reversion.set_comment(chg_string) + try: + uncache_protein_page(slug, request) + except Exception as e: + logger.error("failed to uncache protein: %s", e) return HttpResponseRedirect(protein.get_absolute_url()) else: formset = BleachMeasurementFormSet(queryset=qs) diff --git a/backend/tests_e2e/conftest.py b/backend/tests_e2e/conftest.py index e304d93f..e10bca2b 100644 --- a/backend/tests_e2e/conftest.py +++ b/backend/tests_e2e/conftest.py @@ -154,11 +154,7 @@ def _frontend_assets_need_rebuild(manifest_file) -> bool: sources = list(frontend_src.rglob("*")) packages = Path(__file__).parent.parent.parent / "packages" sources += list(packages.rglob("src/**/*")) - if any(f.stat().st_mtime > manifest_mtime for f in sources if f.is_file() and not f.name.startswith(".")): - return True - - # Everything is up to date - return False + return bool(any(f.stat().st_mtime > manifest_mtime for f in sources if f.is_file() and not f.name.startswith("."))) # Frontend assets are built in pytest_configure hook (runs before Django import) @@ -299,10 +295,7 @@ def on_console(self, msg: ConsoleMessage) -> None: if msg.type in {"debug", "log"}: print(f"[Console {msg.type}] {msg.text} (at {url})") return - if msg.type == "error" and "at " in msg.text: - text = _apply_source_maps_to_stack(msg.text) - else: - text = msg.text + text = _apply_source_maps_to_stack(msg.text) if msg.type == "error" and "at " in msg.text else msg.text if not self._should_ignore(text, url): # Store message with source-mapped text diff --git a/backend/tests_e2e/snapshot_plugin.py b/backend/tests_e2e/snapshot_plugin.py index 012668aa..e41472ea 100644 --- a/backend/tests_e2e/snapshot_plugin.py +++ b/backend/tests_e2e/snapshot_plugin.py @@ -150,10 +150,7 @@ def compare( nonlocal counter if not name: - if counter > 0: - name = f"{test_name}_{counter}.png" - else: - name = f"{test_name}.png" + name = f"{test_name}_{counter}.png" if counter > 0 else f"{test_name}.png" # Use global threshold if no local threshold provided if not threshold: diff --git a/pyproject.toml b/pyproject.toml index 9abd3245..4ffd001a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,7 +133,7 @@ select = [ "LOG", # flake8-logging "G", # flake8-logging-format "TC", # flake8-type-checking - # "SIM", # Common simplification rules + "SIM", # Common simplification rules ] ignore = [ "B905", # `zip()` without an explicit `strict=` parameter From 2f7f731abd932ab99abfac82b8e791ad2a3ac48f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Nov 2025 12:13:22 -0500 Subject: [PATCH 3/5] line length --- backend/config/settings/base.py | 10 ++- backend/config/settings/local.py | 4 +- backend/config/settings/production.py | 6 +- backend/config/urls.py | 1 - backend/conftest.py | 8 +- backend/favit/managers.py | 4 +- backend/favit/models.py | 4 +- backend/fpbase/__init__.py | 4 +- backend/fpbase/decorators.py | 4 +- backend/fpbase/templatetags/fpbase_tags.py | 3 +- backend/fpbase/users/admin.py | 4 +- backend/fpbase/users/models.py | 4 +- backend/fpbase/views.py | 3 +- backend/fpseq/align.py | 4 +- backend/fpseq/mutations.py | 25 ++++-- backend/fpseq/skbio_protein.py | 15 +++- backend/proteins/admin.py | 8 +- backend/proteins/api/_tweaks.py | 12 ++- backend/proteins/api/serializers.py | 8 +- backend/proteins/api/urls.py | 4 +- backend/proteins/api/views.py | 4 +- backend/proteins/extrest/entrez.py | 27 ++++-- backend/proteins/extrest/ga.py | 12 ++- backend/proteins/factories.py | 26 ++++-- backend/proteins/fields.py | 4 +- backend/proteins/filters.py | 12 ++- backend/proteins/forms/forms.py | 41 ++++++--- backend/proteins/forms/microscope.py | 29 ++++-- backend/proteins/forms/spectrum.py | 26 ++++-- .../commands/rebuild_lineage_tree.py | 14 ++- .../commands/sync_snapgene_plasmids.py | 30 +++++-- backend/proteins/models/bleach.py | 12 ++- backend/proteins/models/dye.py | 4 +- backend/proteins/models/efficiency.py | 12 ++- backend/proteins/models/fluorophore.py | 10 ++- backend/proteins/models/lineage.py | 8 +- backend/proteins/models/microscope.py | 49 ++++++++--- backend/proteins/models/protein.py | 24 +++-- backend/proteins/models/spectrum.py | 24 +++-- backend/proteins/models/transition.py | 8 +- backend/proteins/schema/query.py | 4 +- backend/proteins/util/_scipy.py | 8 +- backend/proteins/util/blurb.py | 8 +- backend/proteins/util/efficiency.py | 4 +- backend/proteins/util/helpers.py | 21 ++++- backend/proteins/util/importers.py | 6 +- backend/proteins/util/maintain.py | 4 +- backend/proteins/util/spectra.py | 4 +- backend/proteins/util/spectra_import.py | 4 +- backend/proteins/validators.py | 4 +- backend/proteins/views/ajax.py | 21 +++-- backend/proteins/views/autocomplete.py | 6 +- backend/proteins/views/collection.py | 22 +++-- backend/proteins/views/microscope.py | 33 +++++-- backend/proteins/views/protein.py | 88 ++++++++++++++----- backend/proteins/views/search.py | 13 ++- backend/proteins/views/spectra.py | 23 +++-- backend/references/models.py | 18 +++- backend/references/views.py | 7 +- backend/tests/conftest.py | 4 +- backend/tests/test_api/test_graphql_schema.py | 4 +- backend/tests/test_favit/test_favit.py | 4 +- .../test_fpbase/test_query_optimization.py | 12 ++- .../tests/test_fpbase/test_rate_limiting.py | 15 +++- .../tests/test_fpbase/test_templatetags.py | 13 ++- .../tests/test_proteins/test_ajax_views.py | 3 +- backend/tests/test_proteins/test_forms.py | 26 ++++-- backend/tests/test_proteins/test_importers.py | 9 +- backend/tests/test_proteins/test_lineage.py | 4 +- backend/tests/test_proteins/test_reversion.py | 4 +- backend/tests/test_proteins/test_tasks.py | 7 +- backend/tests/test_proteins/test_views.py | 16 +++- backend/tests/test_users/test_avatar.py | 24 +++-- backend/tests/test_users/test_urls.py | 4 +- backend/tests_e2e/conftest.py | 53 ++++++++--- backend/tests_e2e/snapshot_plugin.py | 33 +++++-- backend/tests_e2e/test_e2e.py | 55 +++++++++--- backend/tests_e2e/test_spectra_viewer.py | 28 ++++-- pyproject.toml | 2 +- scripts/extract_fa_icons.py | 21 +++-- 80 files changed, 872 insertions(+), 278 deletions(-) diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 4b474420..3d326417 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -26,7 +26,8 @@ READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) if READ_DOT_ENV_FILE: - # Operating System Environment variables have precedence over variables defined in the .env file, + # Operating System Environment variables have precedence over variables defined in the + # .env file, # that is to say variables from the .env files will only be used if not defined # as environment variables. env_file = str(ROOT_DIR / ".env") @@ -322,7 +323,8 @@ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } -# By Default swagger ui is available only to admin user(s). You can change permission classes to change that +# By Default swagger ui is available only to admin user(s). You can change permission +# classes to change that # See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings SPECTACULAR_SETTINGS = { "TITLE": "fpbase API", @@ -466,7 +468,9 @@ def add_sentry_context(logger, method_name, event_dict): # Check if sentry_event_id was explicitly passed in extra dict # Try to get it from Sentry SDK's last_event_id() # This works if Django/Sentry integration auto-captured an exception - if "sentry_event_id" not in event_dict and (event_dict.get("exc_info") or "exception" in event_dict): + if "sentry_event_id" not in event_dict and ( + event_dict.get("exc_info") or "exception" in event_dict + ): try: import sentry_sdk diff --git a/backend/config/settings/local.py b/backend/config/settings/local.py index 895bbbe9..c0d66f16 100644 --- a/backend/config/settings/local.py +++ b/backend/config/settings/local.py @@ -29,7 +29,9 @@ EMAIL_PORT = 1025 EMAIL_HOST = "localhost" -EMAIL_BACKEND = env("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend") +EMAIL_BACKEND = env( + "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend" +) if env("MAILGUN_API_KEY", default=False) and env("MAILGUN_DOMAIN", default=False): INSTALLED_APPS += [ diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py index 5199ad47..1aba1584 100644 --- a/backend/config/settings/production.py +++ b/backend/config/settings/production.py @@ -205,7 +205,9 @@ def WHITENOISE_IMMUTABLE_FILE_TEST(path, url): send_default_pii=True, release=HEROKU_SLUG_COMMIT, traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.1), # 10% of all requests - profiles_sample_rate=env.float("SENTRY_PROFILES_SAMPLE_RATE", default=0.05), # 5% of traced requests + profiles_sample_rate=env.float( + "SENTRY_PROFILES_SAMPLE_RATE", default=0.05 + ), # 5% of traced requests ) # Scout APM Configuration @@ -322,7 +324,7 @@ def WHITENOISE_IMMUTABLE_FILE_TEST(path, url): # Enable API and GraphQL rate limiting in production REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = [ - "fpbase.views.SameOriginExemptAnonThrottle", # Custom throttle that exempts same-origin requests + "fpbase.views.SameOriginExemptAnonThrottle", # Custom throttle that exempts same-origin "rest_framework.throttling.UserRateThrottle", ] # NOTE: Rate limiting settings are used by both REST API and GraphQL diff --git a/backend/config/urls.py b/backend/config/urls.py index 5061ac20..d01d7fe6 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -71,7 +71,6 @@ TemplateView.as_view(template_name="pages/bleaching.html"), name="bleaching", ), - # path('mutations/', TemplateView.as_view(template_name='pages/mutations.html'), name='mutations'), path( "robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), diff --git a/backend/conftest.py b/backend/conftest.py index 50cc741f..dbb24b38 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -61,8 +61,12 @@ def mock_get_organism_info(organism_id: str | int): } # Use unittest.mock.patch for session-scoped mocking - patcher1 = unittest.mock.patch("proteins.extrest.entrez.doi_lookup", side_effect=mock_doi_lookup) - patcher2 = unittest.mock.patch("proteins.extrest.entrez.get_organism_info", side_effect=mock_get_organism_info) + patcher1 = unittest.mock.patch( + "proteins.extrest.entrez.doi_lookup", side_effect=mock_doi_lookup + ) + patcher2 = unittest.mock.patch( + "proteins.extrest.entrez.get_organism_info", side_effect=mock_get_organism_info + ) patcher1.start() patcher2.start() diff --git a/backend/favit/managers.py b/backend/favit/managers.py index 92dbed64..f8223b54 100644 --- a/backend/favit/managers.py +++ b/backend/favit/managers.py @@ -112,7 +112,9 @@ def get_favorite(self, user, obj, model=None): return None try: - return self.get_query_set().get(user=user, target_content_type=content_type, target_object_id=obj.id) + return self.get_query_set().get( + user=user, target_content_type=content_type, target_object_id=obj.id + ) except self.model.DoesNotExist: return None diff --git a/backend/favit/models.py b/backend/favit/models.py index e805df09..c2f5db3f 100644 --- a/backend/favit/models.py +++ b/backend/favit/models.py @@ -22,7 +22,9 @@ class Favorite(models.Model): on_delete=models.CASCADE, ) target_content_type_id: int - target_content_type: models.ForeignKey[ContentType] = models.ForeignKey(ContentType, on_delete=models.CASCADE) + target_content_type: models.ForeignKey[ContentType] = models.ForeignKey( + ContentType, on_delete=models.CASCADE + ) target_object_id = models.PositiveIntegerField() target = GenericForeignKey("target_content_type", "target_object_id") timestamp = models.DateTimeField(auto_now_add=True, db_index=True) diff --git a/backend/fpbase/__init__.py b/backend/fpbase/__init__.py index b054ff49..72868879 100644 --- a/backend/fpbase/__init__.py +++ b/backend/fpbase/__init__.py @@ -3,7 +3,9 @@ from .celery import app as celery_app __version__ = "0.1.0" -__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace("-", ".", 1).split(".")]) +__version_info__ = tuple( + [int(num) if num.isdigit() else num for num in __version__.replace("-", ".", 1).split(".")] +) __all__ = ("celery_app",) diff --git a/backend/fpbase/decorators.py b/backend/fpbase/decorators.py index 9ddcee57..ec02e616 100644 --- a/backend/fpbase/decorators.py +++ b/backend/fpbase/decorators.py @@ -44,7 +44,9 @@ def login_required_message_and_redirect( message=default_message, ): if function: - return login_required_message(login_required(function, redirect_field_name, login_url), message) + return login_required_message( + login_required(function, redirect_field_name, login_url), message + ) return lambda deferred_function: login_required_message_and_redirect( deferred_function, redirect_field_name, login_url, message diff --git a/backend/fpbase/templatetags/fpbase_tags.py b/backend/fpbase/templatetags/fpbase_tags.py index c806c039..a0682dbe 100644 --- a/backend/fpbase/templatetags/fpbase_tags.py +++ b/backend/fpbase/templatetags/fpbase_tags.py @@ -13,7 +13,8 @@ ICON_DIR = Path(__file__).parent.parent / "static" / "icons" if not ICON_DIR.exists(): raise RuntimeError( - f"Icon directory does not exist: {ICON_DIR}. Please run 'python scripts/extract_fa_icons.py' to generate it." + f"Icon directory does not exist: {ICON_DIR}. Please run " + "'python scripts/extract_fa_icons.py' to generate it." ) diff --git a/backend/fpbase/users/admin.py b/backend/fpbase/users/admin.py index 69d53013..dc74e262 100644 --- a/backend/fpbase/users/admin.py +++ b/backend/fpbase/users/admin.py @@ -117,7 +117,9 @@ def get_queryset(self, request): super() .get_queryset(request) .prefetch_related("socialaccount_set", "proteincollections", "emailaddress_set") - .annotate(verified=Exists(EmailAddress.objects.filter(user_id=OuterRef("id"), verified=True))) + .annotate( + verified=Exists(EmailAddress.objects.filter(user_id=OuterRef("id"), verified=True)) + ) .annotate( _collections=Count("proteincollections"), _microscopes=Count("microscopes"), diff --git a/backend/fpbase/users/models.py b/backend/fpbase/users/models.py index 0a708210..86b10792 100644 --- a/backend/fpbase/users/models.py +++ b/backend/fpbase/users/models.py @@ -48,7 +48,9 @@ class UserLogin(models.Model): """Represent users' logins, one per record""" user_id: int - user: models.ForeignKey[User] = models.ForeignKey(User, on_delete=models.CASCADE, related_name="logins") + user: models.ForeignKey[User] = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="logins" + ) timestamp = models.DateTimeField(auto_now_add=True) def __str__(self) -> str: diff --git a/backend/fpbase/views.py b/backend/fpbase/views.py index 0d5fbcdc..561d8abe 100644 --- a/backend/fpbase/views.py +++ b/backend/fpbase/views.py @@ -35,7 +35,8 @@ def allow_request(self, request, view): # Check if this is a same-origin request by comparing the referer with the host referer = request.headers.get("referer", "") - # If the referer contains our host (accounting for port differences), it's a same-origin request + # If the referer contains our host (accounting for port differences), it's a + # same-origin request # Note: This checks for the host in the referer URL (e.g., "https://www.fpbase.org/...") # We strip the port from host comparison to handle localhost:8000 vs fpbase.org if referer: diff --git a/backend/fpseq/align.py b/backend/fpseq/align.py index 0dfdf7bb..7a433b41 100644 --- a/backend/fpseq/align.py +++ b/backend/fpseq/align.py @@ -201,7 +201,9 @@ def __str__(self): out = [] for t, q in zip(a, b): out.append(q) - out.append("".join(["*" if x != y else (" " if x == " " else "|") for x, y in zip(t, q)])) + out.append( + "".join(["*" if x != y else (" " if x == " " else "|") for x, y in zip(t, q)]) + ) out.append(t + "\n") return "\n".join(out) diff --git a/backend/fpseq/mutations.py b/backend/fpseq/mutations.py index 99617ed3..8dbd5fa9 100644 --- a/backend/fpseq/mutations.py +++ b/backend/fpseq/mutations.py @@ -129,7 +129,9 @@ def __init__( if self.operation == "sub" and (stop_char or self.stop_idx): raise ValueError("Substitution mutations cannot specify a range (or a stop_char/idx)") if stop_idx and (int(stop_idx) < int(start_idx)): - raise ValueError(f"Stop position ({stop_idx}) must be greater than start position ({start_idx})") + raise ValueError( + f"Stop position ({stop_idx}) must be greater than start position ({start_idx})" + ) if self.operation.endswith("ins"): if not (stop_char and self.stop_idx): print(stop_char) @@ -183,7 +185,9 @@ def __call__(self, seq: Sequence, idx0: int = 1) -> tuple[Sequence, int]: # self._assert_position_consistency(seq, idx0) startpos = self.start_idx - idx0 if startpos > len(seq): # allowing one extra position for extensions - raise IndexError(f"Starting position {self.start_idx} is outside of sequence with length {len(seq)}") + raise IndexError( + f"Starting position {self.start_idx} is outside of sequence with length {len(seq)}" + ) if self.operation == "sub": end = startpos + 1 return (seq[:startpos] + self.new_chars + seq[end:], 0) @@ -246,7 +250,10 @@ def from_str(cls, mutstring, sep="/"): if not m: raise ValueError(f"Mutation code invalid: {mutstring}") if len(m) > 1: - raise ValueError("Multiple mutation codes found. For multiple mutations, create a MutationSet instead") + raise ValueError( + "Multiple mutation codes found. For multiple mutations, " + "create a MutationSet instead" + ) return m[0] @@ -269,7 +276,9 @@ def clear_insertions(ins_start_idx, insertions, extension=False): if extension: out.append(f"*{ins_start_idx + 1}{insertions[0]}ext{insertions[1:]}") else: - out.append(f"{ins_start_char}{ins_start_idx}_{before}{ins_start_idx + 1}ins{insertions}") + out.append( + f"{ins_start_char}{ins_start_idx}_{before}{ins_start_idx + 1}ins{insertions}" + ) def clear_deletions(delstart, numdel, delins, idx): string = "{}{}del".format(delstart, f"_{lastchar + str(idx - 1)}" if numdel > 1 else "") @@ -364,7 +373,9 @@ def __init__(self, muts=None, position_labels=None): mut.stop_label = position_labels[mut.stop_idx - 1] else: try: - mut.start_label = str(len(position_labels) - mut.start_idx + int(position_labels[-1])) + mut.start_label = str( + len(position_labels) - mut.start_idx + int(position_labels[-1]) + ) except Exception: mut.start_label = str(mut.start_idx) @@ -543,7 +554,9 @@ def __eq__(self, other): try: otherm = MutationSet(other).muts except Exception as e: - raise ValueError(f"Could not compare MutationSet object with other: {other}") from e + raise ValueError( + f"Could not compare MutationSet object with other: {other}" + ) from e if not otherm: raise ValueError(f"operation not valid between type MutationSet and {type(other)}") else: diff --git a/backend/fpseq/skbio_protein.py b/backend/fpseq/skbio_protein.py index e5c3afbe..46dd4ee1 100644 --- a/backend/fpseq/skbio_protein.py +++ b/backend/fpseq/skbio_protein.py @@ -138,7 +138,8 @@ def _munge_to_sequence(self, other, method): if isinstance(other, SkbSequence): if type(other) is not type(self): raise TypeError( - f"Cannot use {self.__class__.__name__} and {other.__class__.__name__} together with `{method}`" + f"Cannot use {self.__class__.__name__} and " + f"{other.__class__.__name__} together with `{method}`" ) else: return other @@ -214,9 +215,14 @@ def __getitem__(self, indexable): elif isinstance(indexable, str | bool): raise IndexError(f"Cannot index with {type(indexable).__name__} type: {indexable!r}") - if isinstance(indexable, np.ndarray) and indexable.dtype == bool and len(indexable) != len(self): + if ( + isinstance(indexable, np.ndarray) + and indexable.dtype == bool + and len(indexable) != len(self) + ): raise IndexError( - f"An boolean vector index must be the same length as the sequence ({len(self)}, not {len(indexable)})." + f"An boolean vector index must be the same length as the sequence " + f"({len(self)}, not {len(indexable)})." ) if isinstance(indexable, np.ndarray) and indexable.size == 0: @@ -270,7 +276,8 @@ def _gap_codes(cls): def _validate(self): """https://github.com/biocore/scikit-bio/blob/0.5.4/skbio/sequence/_grammared_sequence.py#L340""" invalid_characters = ( - np.bincount(self._bytes, minlength=self._number_of_extended_ascii_codes) * self._validation_mask + np.bincount(self._bytes, minlength=self._number_of_extended_ascii_codes) + * self._validation_mask ) if np.any(invalid_characters): bad = list(np.where(invalid_characters > 0)[0].astype(np.uint8).view("|S1")) diff --git a/backend/proteins/admin.py b/backend/proteins/admin.py index 3c3730e7..f3735c46 100644 --- a/backend/proteins/admin.py +++ b/backend/proteins/admin.py @@ -284,7 +284,9 @@ def owner(self, obj): owner = obj.owner # FluorState is a base class - resolve to the actual subclass admin if isinstance(owner, FluorState): - model_name = "state" if owner.entity_type == FluorState.EntityTypes.PROTEIN else "dyestate" + model_name = ( + "state" if owner.entity_type == FluorState.EntityTypes.PROTEIN else "dyestate" + ) else: model_name = owner._meta.model.__name__.lower() @@ -721,7 +723,9 @@ class Meta: fields = ("protein", "parent", "mutation", "root_node", "rootmut") readonly_fields = "root_node" - parent = forms.ModelChoiceField(required=False, queryset=Lineage.objects.prefetch_related("protein").all()) + parent = forms.ModelChoiceField( + required=False, queryset=Lineage.objects.prefetch_related("protein").all() + ) root_node = forms.ModelChoiceField(queryset=Lineage.objects.prefetch_related("protein").all()) diff --git a/backend/proteins/api/_tweaks.py b/backend/proteins/api/_tweaks.py index a3816460..20758496 100644 --- a/backend/proteins/api/_tweaks.py +++ b/backend/proteins/api/_tweaks.py @@ -123,10 +123,14 @@ def get_field_name(key, field): field_name = str(get_field_name(key, field)) custom_required_message = self.custom_required_errors.get(key, self.required_error) if custom_required_message: - field.error_messages["required"] = custom_required_message.format(fieldname=field_name) + field.error_messages["required"] = custom_required_message.format( + fieldname=field_name + ) custom_blank_message = self.custom_blank_errors.get(key, self.blank_error) if custom_blank_message: - field.error_messages["blank"] = custom_blank_message.format(fieldname=field_name) + field.error_messages["blank"] = custom_blank_message.format( + fieldname=field_name + ) # required fields override required_fields = [] @@ -203,7 +207,9 @@ def to_representation(self, instance): for field in fields: # ++ change to the original code from DRF - if not self.check_if_needs_serialization(field.field_name, only_fields, include_fields, on_demand_fields): + if not self.check_if_needs_serialization( + field.field_name, only_fields, include_fields, on_demand_fields + ): continue # -- change diff --git a/backend/proteins/api/serializers.py b/backend/proteins/api/serializers.py index ef8a0eaf..629ae6c1 100644 --- a/backend/proteins/api/serializers.py +++ b/backend/proteins/api/serializers.py @@ -133,7 +133,9 @@ class ProteinSerializer(ModelSerializer): # url = serializers.CharField(source='get_absolute_url', read_only=True) states = StateSerializer(many=True, read_only=True) transitions = StateTransitionSerializer(many=True, read_only=True) - doi = serializers.SlugRelatedField(source="primary_reference", slug_field="doi", read_only=True) + doi = serializers.SlugRelatedField( + source="primary_reference", slug_field="doi", read_only=True + ) class Meta: model = Protein @@ -159,7 +161,9 @@ class Meta: class ProteinSerializer2(ModelSerializer): states = serializers.SlugRelatedField(many=True, read_only=True, slug_field="slug") transitions = serializers.IntegerField(source="transitions.count", read_only=True) - doi = serializers.SlugRelatedField(source="primary_reference", slug_field="doi", read_only=True) + doi = serializers.SlugRelatedField( + source="primary_reference", slug_field="doi", read_only=True + ) class Meta: model = Protein diff --git a/backend/proteins/api/urls.py b/backend/proteins/api/urls.py index 3a4623a7..21af7a0d 100644 --- a/backend/proteins/api/urls.py +++ b/backend/proteins/api/urls.py @@ -38,6 +38,8 @@ # non-normal endpoints path("proteins/spectraslugs/", RedirectView.as_view(url="/api/spectra-list/", permanent=True)), path("spectra-list/", views.spectra_list, name="spectra-list"), - path("proteins/ocinfo/", RedirectView.as_view(url="/api/optical-configs-list/", permanent=True)), + path( + "proteins/ocinfo/", RedirectView.as_view(url="/api/optical-configs-list/", permanent=True) + ), path("optical-configs-list/", views.optical_configs_list, name="ocinfo"), ] diff --git a/backend/proteins/api/views.py b/backend/proteins/api/views.py index a5ab9e7f..ebe543cb 100644 --- a/backend/proteins/api/views.py +++ b/backend/proteins/api/views.py @@ -32,7 +32,9 @@ def _spectra_etag(request: HttpRequest) -> str: """Compute weak ETag for spectra list based on model versions.""" - version = get_model_version(pm.Camera, pm.Dye, pm.Filter, pm.Light, pm.Protein, pm.Spectrum, pm.State) + version = get_model_version( + pm.Camera, pm.Dye, pm.Filter, pm.Light, pm.Protein, pm.Spectrum, pm.State + ) return f'W/"{version}"' diff --git a/backend/proteins/extrest/entrez.py b/backend/proteins/extrest/entrez.py index 02220a48..943ca304 100644 --- a/backend/proteins/extrest/entrez.py +++ b/backend/proteins/extrest/entrez.py @@ -50,9 +50,9 @@ def _parse_crossref(doidict: dict) -> DoiInfo: } if title := out["title"]: out["title"] = title[0] - dp = doidict.get("published-online", doidict.get("published-print", doidict.get("issued", {}))).get( - "date-parts", [None] - )[0] + dp = doidict.get( + "published-online", doidict.get("published-print", doidict.get("issued", {})) + ).get("date-parts", [None])[0] dp.append(1) if dp and len(dp) == 1 else None dp.append(1) if dp and len(dp) == 2 else None out["date"] = datetime.date(*dp) if dp and all(dp) else None @@ -183,7 +183,10 @@ def _fetch_gb_seqs(gbids): "Invalid GenBank accession '%s' in database. " "This entry must be updated with a valid accession number. ", id, - extra={"accession": id, "reason": "deprecated_gi_number" if id.isdigit() else "unrecognized_format"}, + extra={ + "accession": id, + "reason": "deprecated_gi_number" if id.isdigit() else "unrecognized_format", + }, ) records = {} @@ -191,7 +194,10 @@ def _fetch_gb_seqs(gbids): try: with Entrez.efetch(db="nuccore", id=nucs, rettype="fasta", retmode="text") as handle: records.update( - {x.id.split(".")[0]: str(x.translate().seq).strip("*") for x in SeqIO.parse(handle, "fasta")} + { + x.id.split(".")[0]: str(x.translate().seq).strip("*") + for x in SeqIO.parse(handle, "fasta") + } ) except Exception as e: logger.error( @@ -202,7 +208,12 @@ def _fetch_gb_seqs(gbids): if len(prots): try: with Entrez.efetch(db="protein", id=prots, rettype="fasta", retmode="text") as handle: - records.update({x.id.split(".")[0]: str(x.seq).strip("*") for x in SeqIO.parse(handle, "fasta")}) + records.update( + { + x.id.split(".")[0]: str(x.seq).strip("*") + for x in SeqIO.parse(handle, "fasta") + } + ) except Exception as e: logger.error( "Failed to fetch protein sequences from NCBI. Error: %s", @@ -249,7 +260,9 @@ def _parse_gbnuc_record(record): if annotations: refs = annotations.get("references") if refs: - D["pmids"] = [getattr(r, "pubmed_id", None) for r in refs if getattr(r, "pubmed_id", False)] + D["pmids"] = [ + getattr(r, "pubmed_id", None) for r in refs if getattr(r, "pubmed_id", False) + ] D["organism"] = annotations.get("organism", None) nuc = annotations.get("accessions", []) if len(nuc): diff --git a/backend/proteins/extrest/ga.py b/backend/proteins/extrest/ga.py index ad62f140..2ed09a1e 100644 --- a/backend/proteins/extrest/ga.py +++ b/backend/proteins/extrest/ga.py @@ -32,7 +32,8 @@ def get_client() -> "BetaAnalyticsDataClient": "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_x509_cert_url": ( - "https://www.googleapis.com/robot/v1/metadata/x509/" + settings.GOOGLE_API_CLIENT_EMAIL.replace("@", "%40") + "https://www.googleapis.com/robot/v1/metadata/x509/" + + settings.GOOGLE_API_CLIENT_EMAIL.replace("@", "%40") ), "universe_domain": "googleapis.com", } @@ -55,7 +56,9 @@ def cached_ga_popular(max_age=60 * 60 * 24): return results -def ga_popular_proteins(client: BetaAnalyticsDataClient, days: int = 30) -> list[tuple[str, str, float]]: +def ga_popular_proteins( + client: BetaAnalyticsDataClient, days: int = 30 +) -> list[tuple[str, str, float]]: """Return a list of proteins with their page views in the last `days` days. Returns a list of tuples, each containing: `(protein slug, protein name, view percentage)` @@ -97,7 +100,10 @@ def ga_popular_proteins(client: BetaAnalyticsDataClient, days: int = 30) -> list total_views = sum(slug2count.values()) with_percent = sorted( - ((slug, slug2name.get(slug, slug), 100 * count / total_views) for slug, count in slug2count.items()), + ( + (slug, slug2name.get(slug, slug), 100 * count / total_views) + for slug, count in slug2count.items() + ), key=lambda x: x[2], reverse=True, ) diff --git a/backend/proteins/factories.py b/backend/proteins/factories.py index ce0818f6..63b61ab4 100644 --- a/backend/proteins/factories.py +++ b/backend/proteins/factories.py @@ -34,10 +34,12 @@ _BP_TYPE = {Filter.BP, Filter.BPM, Filter.BPX} FRUITS = [ - 'Apple', 'Banana', 'Orange', 'Strawberry', 'Mango', 'Grapes', 'Pineapple', 'Watermelon', 'Kiwi', + 'Apple', 'Banana', 'Orange', 'Strawberry', 'Mango', 'Grapes', 'Pineapple', 'Watermelon', + 'Kiwi', 'Pear', 'Cherry', 'Peach', 'Plum', 'Lemon', 'Lime', 'Blueberry', 'Raspberry', 'Blackberry', 'Pomegranate', 'Coconut', 'Avocado', 'Grapefruit', 'Cantaloupe', 'Fig', 'Guava', 'Honeydew', - 'Lychee', 'Mandarin', 'Nectarine', 'Passionfruit', 'Papaya', 'Apricot', 'Cranberry', 'Dragonfruit', + 'Lychee', 'Mandarin', 'Nectarine', 'Passionfruit', 'Papaya', 'Apricot', 'Cranberry', + 'Dragonfruit', 'Starfruit', 'Persimmon', 'Rambutan', 'Tangerine', 'Clementine', 'Mulberry', 'Cactus', 'Quince', 'Date', 'Jackfruit', 'Kumquat', 'Lingonberry', 'Loquat', 'Mangosteen', 'Pitaya', 'Plantain', 'PricklyPear', 'Tamarind', 'UgliFruit', 'Yuzu', 'Boysenberry', 'Cherimoya', @@ -265,7 +267,9 @@ class Meta: model = Spectrum category = factory.fuzzy.FuzzyChoice(Spectrum.CATEGORIES, getter=lambda c: c[0]) - subtype = factory.LazyAttribute(lambda o: random.choice(Spectrum.category_subtypes[o.category])) + subtype = factory.LazyAttribute( + lambda o: random.choice(Spectrum.category_subtypes[o.category]) + ) data = factory.LazyAttribute(_build_spectral_data) @@ -283,9 +287,15 @@ class Meta: name = factory.Sequence(lambda n: f"TestFilter{n}") subtype = factory.fuzzy.FuzzyChoice(Spectrum.category_subtypes["f"]) - bandcenter = factory.LazyAttribute(lambda o: random.randint(400, 900) if o.subtype in _BP_TYPE else None) - bandwidth = factory.LazyAttribute(lambda o: random.randint(10, 20) if o.subtype in _BP_TYPE else None) - edge = factory.LazyAttribute(lambda o: random.randint(400, 900) if o.subtype not in _BP_TYPE else None) + bandcenter = factory.LazyAttribute( + lambda o: random.randint(400, 900) if o.subtype in _BP_TYPE else None + ) + bandwidth = factory.LazyAttribute( + lambda o: random.randint(10, 20) if o.subtype in _BP_TYPE else None + ) + edge = factory.LazyAttribute( + lambda o: random.randint(400, 900) if o.subtype not in _BP_TYPE else None + ) spectrum = factory.RelatedFactory( "proteins.factories.SpectrumFactory", @@ -382,7 +392,9 @@ def create_egfp() -> Protein: seq_validated=True, agg=Protein.AggChoices.MONOMER, pdb=["4EUL", "2Y0G"], - parent_organism=OrganismFactory(scientific_name="Aequorea victoria", id=6100, division="hydrozoans"), + parent_organism=OrganismFactory( + scientific_name="Aequorea victoria", id=6100, division="hydrozoans" + ), primary_reference=ReferenceFactory(doi="10.1016/0378-1119(95)00685-0"), genbank="AAB02572", uniprot="C5MKY7", diff --git a/backend/proteins/fields.py b/backend/proteins/fields.py index 2840452e..875e5bf6 100644 --- a/backend/proteins/fields.py +++ b/backend/proteins/fields.py @@ -92,7 +92,9 @@ def __str__(self): def width(self, height=0.5): try: upindex = next(x[0] for x in enumerate(self.y) if x[1] > height) - downindex = len(self.y) - next(x[0] for x in enumerate(reversed(self.y)) if x[1] > height) + downindex = len(self.y) - next( + x[0] for x in enumerate(reversed(self.y)) if x[1] > height + ) return (self.x[upindex], self.x[downindex]) except Exception: return False diff --git a/backend/proteins/filters.py b/backend/proteins/filters.py index b715182f..e0c3db4c 100644 --- a/backend/proteins/filters.py +++ b/backend/proteins/filters.py @@ -107,8 +107,12 @@ class ProteinFilter(filters.FilterSet): name__icontains = django_filters.CharFilter( field_name="name", method="name_or_alias_icontains", lookup_expr="icontains" ) - switch_type__ne = django_filters.ChoiceFilter(choices=Protein.SwitchingChoices, method="switch_type__notequal") - cofactor__ne = django_filters.ChoiceFilter(choices=Protein.CofactorChoices, method="cofactor__notequal") + switch_type__ne = django_filters.ChoiceFilter( + choices=Protein.SwitchingChoices, method="switch_type__notequal" + ) + cofactor__ne = django_filters.ChoiceFilter( + choices=Protein.CofactorChoices, method="cofactor__notequal" + ) parent_organism__ne = django_filters.ModelChoiceFilter( queryset=Organism.objects.all(), method="parent_organism__notequal" ) @@ -201,7 +205,9 @@ def parent_organism__notequal(self, queryset, name, value): def get_specbright(self, queryset, name, value): qsALL = list(queryset.all()) - ids = [P.id for P in qsALL if P.default_state and P.default_state.local_brightness == value] + ids = [ + P.id for P in qsALL if P.default_state and P.default_state.local_brightness == value + ] return queryset.filter(id__in=ids) def get_specbright_lt(self, queryset, name, value): diff --git a/backend/proteins/forms/forms.py b/backend/proteins/forms/forms.py index bc3d7081..ecb32afa 100644 --- a/backend/proteins/forms/forms.py +++ b/backend/proteins/forms/forms.py @@ -53,7 +53,9 @@ def check_existence(form, fieldname, value): query = Protein.objects.filter(slug=slug) query = query | Protein.objects.filter(name__iexact=value) query = query | Protein.objects.filter(name__iexact=value.replace(" ", "")) - query = query | Protein.objects.filter(name__iexact=value.replace(" ", "").replace("monomeric", "m")) + query = query | Protein.objects.filter( + name__iexact=value.replace(" ", "").replace("monomeric", "m") + ) else: query = Protein.objects.filter(**{fieldname: value}).exclude(id=form.instance.id) @@ -62,7 +64,8 @@ def check_existence(form, fieldname, value): raise forms.ValidationError( mark_safe( f'' - f"{prot.name} already has this {Protein._meta.get_field(fieldname).verbose_name.lower()}" + f"{prot.name} already has this " + f"{Protein._meta.get_field(fieldname).verbose_name.lower()}" ) ) return value @@ -99,7 +102,9 @@ class SelectAddWidget(forms.widgets.Select): class ProteinForm(forms.ModelForm): """Form class for user-facing protein creation/submission form""" - reference_doi = DOIField(required=False, help_text="e.g. 10.1038/nmeth.2413", label="Reference DOI") + reference_doi = DOIField( + required=False, help_text="e.g. 10.1038/nmeth.2413", label="Reference DOI" + ) seq = SequenceField(required=False, help_text="Amino acid sequence", label="Sequence") # reference_pmid = forms.CharField(max_length=24, label='Reference Pubmed ID', # required=False, help_text='e.g. 23524392 (must provide either DOI or PMID)') @@ -217,7 +222,9 @@ def clean_name(self): def clean_seq(self): seq = self.cleaned_data["seq"] if set(seq) == set("ACTG"): - raise forms.ValidationError("Please enter an amino acid sequence... not a DNA sequence.") + raise forms.ValidationError( + "Please enter an amino acid sequence... not a DNA sequence." + ) self.cleaned_data["seq"] = "".join(seq.split()).upper() return check_existence(self, "seq", self.cleaned_data["seq"]) @@ -400,7 +407,9 @@ def __init__(self, *args, **kwargs): ) -StateTransitionFormSet = inlineformset_factory(Protein, StateTransition, form=StateTransitionForm, extra=1) +StateTransitionFormSet = inlineformset_factory( + Protein, StateTransition, form=StateTransitionForm, extra=1 +) class LineageForm(forms.ModelForm): @@ -433,13 +442,19 @@ def clean(self): parent = self.cleaned_data.get("parent") mutation = self.cleaned_data.get("mutation") if (parent and not mutation) or (mutation and not parent): - raise forms.ValidationError("Both parent and mutation are required when providing lineage information") + raise forms.ValidationError( + "Both parent and mutation are required when providing lineage information" + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) instance = getattr(self, "instance", None) - if instance and instance.pk and (instance.protein.seq_validated or instance.children.exists()): + if ( + instance + and instance.pk + and (instance.protein.seq_validated or instance.children.exists()) + ): self.fields["mutation"].widget.attrs["readonly"] = True self.fields["parent"].widget.attrs["readonly"] = True @@ -455,7 +470,9 @@ def __init__(self, *args, **kwargs): ) -LineageFormSet = inlineformset_factory(Protein, Lineage, form=LineageForm, extra=1, can_delete=False) +LineageFormSet = inlineformset_factory( + Protein, Lineage, form=LineageForm, extra=1, can_delete=False +) class CollectionForm(forms.ModelForm): @@ -488,7 +505,9 @@ def clean_name(self): class BleachMeasurementForm(forms.ModelForm): - reference_doi = DOIField(required=False, help_text="e.g. 10.1038/nmeth.2413", label="Reference DOI") + reference_doi = DOIField( + required=False, help_text="e.g. 10.1038/nmeth.2413", label="Reference DOI" + ) class Meta: model = BleachMeasurement @@ -585,7 +604,9 @@ def __init__(self, *args, **kwargs): class BleachComparisonForm(forms.ModelForm): - reference_doi = DOIField(required=True, help_text="e.g. 10.1038/nmeth.2413", label="Reference DOI") + reference_doi = DOIField( + required=True, help_text="e.g. 10.1038/nmeth.2413", label="Reference DOI" + ) class Meta: model = BleachMeasurement diff --git a/backend/proteins/forms/microscope.py b/backend/proteins/forms/microscope.py index c2713a31..62da68da 100644 --- a/backend/proteins/forms/microscope.py +++ b/backend/proteins/forms/microscope.py @@ -106,13 +106,21 @@ class Meta: help_texts = { "name": "Name of this microscope or set of filter configurations", "description": "This text will appear below the name on your microscope page", - "managers": "Grant others permission to edit this page (comma separated list of email addresses)", + "managers": ( + "Grant others permission to edit this page " + "(comma separated list of email addresses)" + ), "extra_lasers": "Comma separated list of integers (300-1600)", - "collection": "Only show fluorophores from a custom collection (leave blank to allow all proteins)", + "collection": ( + "Only show fluorophores from a custom collection " + "(leave blank to allow all proteins)" + ), } widgets = { "name": forms.widgets.TextInput(attrs={"class": "textinput textInput form-control"}), - "description": forms.widgets.TextInput(attrs={"class": "textinput textInput form-control"}), + "description": forms.widgets.TextInput( + attrs={"class": "textinput textInput form-control"} + ), "detector": forms.widgets.Select(attrs={"class": "selectmultiple form-control"}), "light_source": forms.widgets.Select(attrs={"class": "selectmultiple form-control"}), } @@ -253,7 +261,9 @@ def lookup(fname, n=None): splt = [] for item in _splt: if brackets.search(item): - splt.append([n.strip() for n in brackets.sub("", item).split(",") if n.strip()]) + splt.append( + [n.strip() for n in brackets.sub("", item).split(",") if n.strip()] + ) else: if item.endswith(","): item = item[:-1] @@ -265,7 +275,8 @@ def lookup(fname, n=None): if len(splt) not in (4, 5): self.add_error( "optical_configs", - f"Lines must have 4 or 5 comma-separated fields but this one has {len(splt)}: {line}", + f"Lines must have 4 or 5 comma-separated fields but this one has " + f"{len(splt)}: {line}", ) for n, f in enumerate(splt): if n == 0: @@ -377,14 +388,18 @@ class Meta: help_texts = { "light": "laser overrides light source", "name": "name of this optical config", - "comments": "When present, comments will appear below the selected optical configuration", + "comments": ( + "When present, comments will appear below the selected optical configuration" + ), } widgets = { "name": forms.widgets.TextInput(attrs={"class": "textinput textInput form-control"}), "camera": forms.widgets.Select(attrs={"class": "form-control form-select"}), "light": forms.widgets.Select(attrs={"class": "form-control form-select"}), "laser": forms.widgets.NumberInput(attrs={"class": "numberinput form-control"}), - "comments": forms.widgets.TextInput(attrs={"class": "textinput textInput form-control"}), + "comments": forms.widgets.TextInput( + attrs={"class": "textinput textInput form-control"} + ), } def is_valid(self): diff --git a/backend/proteins/forms/spectrum.py b/backend/proteins/forms/spectrum.py index d74f9fa7..89ca3cc9 100644 --- a/backend/proteins/forms/spectrum.py +++ b/backend/proteins/forms/spectrum.py @@ -21,7 +21,8 @@ class SpectrumFormField(forms.CharField): def __init__(self, *args, **kwargs): if "help_text" not in kwargs: kwargs["help_text"] = ( - "List of [wavelength, value] pairs, e.g. [[300, 0.5], [301, 0.6],... ]. File data takes precedence." + "List of [wavelength, value] pairs, e.g. [[300, 0.5], [301, 0.6],... ]. " + "File data takes precedence." ) super().__init__(*args, **kwargs) @@ -47,7 +48,9 @@ class SpectrumForm(forms.ModelForm): ) owner = forms.CharField( max_length=100, - label=mark_safe('Owner Name*'), + label=mark_safe( + 'Owner Name*' + ), required=False, help_text="Name of protein, dye, filter, etc...", ) @@ -57,13 +60,16 @@ class SpectrumForm(forms.ModelForm): file = forms.FileField( required=False, label="File Upload", - help_text="2 column CSV/TSV file with wavelengths in first column and data in second column", + help_text=( + "2 column CSV/TSV file with wavelengths in first column and data in second column" + ), ) confirmation = forms.BooleanField( required=True, label=mark_safe( "I understand that I am adding a spectrum to the public " - "FPbase spectra database, and confirm that I have verified the validity of the data" + "FPbase spectra database, and confirm that I have verified the validity of the " + "data" ), ) @@ -153,7 +159,9 @@ def save(self, commit=True): # Filter, Camera, Light - create owner directly owner_model = apps.get_model("proteins", self.lookup[cat][1]) owner_name = self.cleaned_data.get("owner") - owner_obj, created = owner_model.objects.get_or_create(name=owner_name, defaults={"created_by": self.user}) + owner_obj, created = owner_model.objects.get_or_create( + name=owner_name, defaults={"created_by": self.user} + ) if not created: owner_obj.updated_by = self.user owner_obj.save() @@ -205,7 +213,9 @@ def clean_owner_fluor(self): "owner": owner_fluor, "stype": first.get_subtype_display().lower(), "n": "n" if stype != Spectrum.TWOP else "", - "status": " (pending)" if first.status == Spectrum.STATUS.pending else "", + "status": " (pending)" + if first.status == Spectrum.STATUS.pending + else "", }, code="owner_exists", ), @@ -228,7 +238,9 @@ def clean_owner(self): return owner dye_state = dye.states.filter(name=FluorState.DEFAULT_NAME).first() if dye_state and dye_state.spectra.filter(subtype=stype).exists(): - stype_display = dye_state.spectra.filter(subtype=stype).first().get_subtype_display() + stype_display = ( + dye_state.spectra.filter(subtype=stype).first().get_subtype_display() + ) self.add_error( "owner", forms.ValidationError( diff --git a/backend/proteins/management/commands/rebuild_lineage_tree.py b/backend/proteins/management/commands/rebuild_lineage_tree.py index 80129bdc..800ee0cb 100644 --- a/backend/proteins/management/commands/rebuild_lineage_tree.py +++ b/backend/proteins/management/commands/rebuild_lineage_tree.py @@ -41,11 +41,17 @@ def handle(self, **options): ) if corrupt_nodes: - self.stdout.write(self.style.WARNING(f"\nFound {len(corrupt_nodes)} nodes with tree_id inconsistencies:")) + self.stdout.write( + self.style.WARNING( + f"\nFound {len(corrupt_nodes)} nodes with tree_id inconsistencies:" + ) + ) for node in corrupt_nodes[:10]: # Show first 10 self.stdout.write(self.style.WARNING(node)) if len(corrupt_nodes) > 10: - self.stdout.write(self.style.WARNING(f" ... and {len(corrupt_nodes) - 10} more")) + self.stdout.write( + self.style.WARNING(f" ... and {len(corrupt_nodes) - 10} more") + ) else: self.stdout.write(self.style.SUCCESS("\n No tree_id inconsistencies detected.")) @@ -68,7 +74,9 @@ def handle(self, **options): self.stdout.write(f" Trees after: {new_num_trees}") if corrupt_nodes: - self.stdout.write(self.style.SUCCESS(f"\n Fixed {len(corrupt_nodes)} tree_id inconsistencies")) + self.stdout.write( + self.style.SUCCESS(f"\n Fixed {len(corrupt_nodes)} tree_id inconsistencies") + ) except Exception as e: self.stdout.write(self.style.ERROR(f"\n✗ Rebuild failed: {e}")) diff --git a/backend/proteins/management/commands/sync_snapgene_plasmids.py b/backend/proteins/management/commands/sync_snapgene_plasmids.py index 47eaa1bd..e91ab28f 100644 --- a/backend/proteins/management/commands/sync_snapgene_plasmids.py +++ b/backend/proteins/management/commands/sync_snapgene_plasmids.py @@ -51,7 +51,9 @@ def handle(self, **options): return # Sync plasmids to database - created_count, updated_count, unchanged_count, removed_count = self._sync_plasmids(plasmid_data) + created_count, updated_count, unchanged_count, removed_count = self._sync_plasmids( + plasmid_data + ) self.stdout.write( self.style.SUCCESS( f"\n✓ Synced plasmids: {created_count} created, {updated_count} updated, " @@ -93,7 +95,9 @@ def _extract_plasmid_data(self, content: str) -> list[dict]: return [] try: - data = json.loads(match.group(1) if match.group(0).startswith("var") else f"[{match.group(1)}]") + data = json.loads( + match.group(1) if match.group(0).startswith("var") else f"[{match.group(1)}]" + ) # Handle different possible structures if isinstance(data, dict) and "sequences" in data: return data["sequences"] @@ -109,7 +113,9 @@ def _preview_plasmids(self, plasmids: Sequence[dict]) -> None: """Show preview of plasmids that would be synced.""" self.stdout.write("\nPreview of plasmids (first 10):") for p in plasmids: - self.stdout.write(f" - {p.get('plasmidName', 'Unknown')} ({p.get('plasmidID', 'Unknown')})") + self.stdout.write( + f" - {p.get('plasmidName', 'Unknown')} ({p.get('plasmidID', 'Unknown')})" + ) def _sync_plasmids(self, plasmid_data: list[dict]) -> tuple[int, int, int, int]: """Sync plasmid data using get() then create-only logic. @@ -132,7 +138,11 @@ def _sync_plasmids(self, plasmid_data: list[dict]) -> tuple[int, int, int, int]: for data in plasmid_data: plasmid_id = data.get("plasmidID") if not plasmid_id: - self.stdout.write(self.style.WARNING("✗ no plasmidID found... has the page structure changed?")) + self.stdout.write( + self.style.WARNING( + "✗ no plasmidID found... has the page structure changed?" + ) + ) continue fetched_plasmid_ids.add(plasmid_id) @@ -150,14 +160,18 @@ def _sync_plasmids(self, plasmid_data: list[dict]) -> tuple[int, int, int, int]: SnapGenePlasmid.objects.get(plasmid_id=plasmid_id, **defaults) unchanged_count += 1 except SnapGenePlasmid.DoesNotExist: - _, created = SnapGenePlasmid.objects.update_or_create(plasmid_id=plasmid_id, defaults=defaults) + _, created = SnapGenePlasmid.objects.update_or_create( + plasmid_id=plasmid_id, defaults=defaults + ) if created: created_count += 1 else: updated_count += 1 # Remove plasmids that are no longer on SnapGene's website - removed_count, _ = SnapGenePlasmid.objects.exclude(plasmid_id__in=fetched_plasmid_ids).delete() + removed_count, _ = SnapGenePlasmid.objects.exclude( + plasmid_id__in=fetched_plasmid_ids + ).delete() return created_count, updated_count, unchanged_count, removed_count @@ -175,7 +189,9 @@ def _match_plasmids_to_proteins(self) -> int: protein_name = protein.name # Pattern to match exact name or plasmid vectors (e.g., pEGFP, pEGFP-N1, pEGFP-C2) - is_plasmid_vector = re.compile(rf"^p{re.escape(protein_name)}(-[CN]?\d+)?$", re.IGNORECASE) + is_plasmid_vector = re.compile( + rf"^p{re.escape(protein_name)}(-[CN]?\d+)?$", re.IGNORECASE + ) matching_plasmids = [ plasmid for plasmid in SnapGenePlasmid.objects.all() diff --git a/backend/proteins/models/bleach.py b/backend/proteins/models/bleach.py index 4e5fc187..6eabadc3 100644 --- a/backend/proteins/models/bleach.py +++ b/backend/proteins/models/bleach.py @@ -57,8 +57,12 @@ class BleachMeasurement(Authorable, TimeStampedModel): validators=[MinValueValidator(-1)], help_text="If not reported, use '-1'", ) - units = models.CharField(max_length=100, blank=True, verbose_name="Power Units", help_text="e.g. W/cm2") - light = models.CharField(max_length=2, choices=LIGHT_CHOICES, blank=True, verbose_name="Light Source") + units = models.CharField( + max_length=100, blank=True, verbose_name="Power Units", help_text="e.g. W/cm2" + ) + light = models.CharField( + max_length=2, choices=LIGHT_CHOICES, blank=True, verbose_name="Light Source" + ) bandcenter = models.PositiveSmallIntegerField( blank=True, null=True, @@ -93,7 +97,9 @@ class BleachMeasurement(Authorable, TimeStampedModel): verbose_name="In cells?", help_text="protein expressed in living cells", ) - cell_type = models.CharField(max_length=60, blank=True, verbose_name="Cell Type", help_text="e.g. HeLa") + cell_type = models.CharField( + max_length=60, blank=True, verbose_name="Cell Type", help_text="e.g. HeLa" + ) reference_id: int | None reference: models.ForeignKey[Reference | None] = models.ForeignKey( Reference, diff --git a/backend/proteins/models/dye.py b/backend/proteins/models/dye.py index 3c26a72d..01e24154 100644 --- a/backend/proteins/models/dye.py +++ b/backend/proteins/models/dye.py @@ -74,7 +74,9 @@ class DyeState(FluorState): """ dye_id: int - dye: models.ForeignKey["Dye"] = models.ForeignKey(Dye, on_delete=models.CASCADE, related_name="states") + dye: models.ForeignKey["Dye"] = models.ForeignKey( + Dye, on_delete=models.CASCADE, related_name="states" + ) if TYPE_CHECKING: fluorophore_ptr: FluorState # added by Django MTI diff --git a/backend/proteins/models/efficiency.py b/backend/proteins/models/efficiency.py index be311ac8..9534a2db 100644 --- a/backend/proteins/models/efficiency.py +++ b/backend/proteins/models/efficiency.py @@ -15,20 +15,26 @@ class OcFluorEffQuerySet(models.QuerySet): def outdated(self): fluor_objs = FluorState.objects.filter(id=OuterRef("fluor_id")) - spectra_mod = fluor_objs.annotate(latest_spec=Max("spectra__modified")).values("latest_spec")[:1] + spectra_mod = fluor_objs.annotate(latest_spec=Max("spectra__modified")).values( + "latest_spec" + )[:1] fluor_mod = fluor_objs.values("modified")[:1] return self.annotate( fluor_mod=Subquery(fluor_mod), spec_mod=Subquery(spectra_mod), ).filter( - Q(modified__lt=F("fluor_mod")) | Q(modified__lt=F("spec_mod")) | Q(modified__lt=F("oc__modified")), + Q(modified__lt=F("fluor_mod")) + | Q(modified__lt=F("spec_mod")) + | Q(modified__lt=F("oc__modified")), ) class OcFluorEff(TimeStampedModel): oc_id: int - oc: models.ForeignKey["OpticalConfig"] = models.ForeignKey("OpticalConfig", on_delete=models.CASCADE) + oc: models.ForeignKey["OpticalConfig"] = models.ForeignKey( + "OpticalConfig", on_delete=models.CASCADE + ) fluor_id: int fluor: models.ForeignKey[FluorState] = models.ForeignKey( FluorState, on_delete=models.CASCADE, related_name="oc_effs" diff --git a/backend/proteins/models/fluorophore.py b/backend/proteins/models/fluorophore.py index 443a57c2..947e4d7d 100644 --- a/backend/proteins/models/fluorophore.py +++ b/backend/proteins/models/fluorophore.py @@ -140,12 +140,18 @@ def rebuild_attributes(self) -> None: # Fetch pinned measurements in one query pinned_source_map = cast("dict[str, int]", self.pinned_source_map) - pinned_by_id = {m.id: m for m in self.measurements.filter(id__in=pinned_source_map.values())} + pinned_by_id = { + m.id: m for m in self.measurements.filter(id__in=pinned_source_map.values()) + } # Handle pinned fields first (admin overrides) pinned_fields: set[str] = set() for field, mid in pinned_source_map.items(): - if field in measurable_fields and (m := pinned_by_id.get(mid)) and (val := getattr(m, field)) is not None: + if ( + field in measurable_fields + and (m := pinned_by_id.get(mid)) + and (val := getattr(m, field)) is not None + ): new_values[field] = val new_source_map[field] = m.id pinned_fields.add(field) diff --git a/backend/proteins/models/lineage.py b/backend/proteins/models/lineage.py index 26b3987a..b5b9404f 100644 --- a/backend/proteins/models/lineage.py +++ b/backend/proteins/models/lineage.py @@ -43,7 +43,9 @@ class Lineage(MPTTModel, TimeStampedModel, Authorable): "Protein", on_delete=models.CASCADE, related_name="lineage" ) parent_id: int | None - parent = TreeForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children") + parent = TreeForeignKey( + "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" + ) reference_id: int | None reference: models.ForeignKey[Reference | None] = models.ForeignKey( Reference, @@ -119,7 +121,9 @@ def derive_mutation(self, root=None): if not isinstance(root, Protein): root = self.get_root().protein if root.seq: - return self.parent.protein.seq.mutations_to(self.protein.seq, reference=root.seq) + return self.parent.protein.seq.mutations_to( + self.protein.seq, reference=root.seq + ) else: ms = self.parent.protein.seq.mutations_to(self.protein.seq) return ms diff --git a/backend/proteins/models/microscope.py b/backend/proteins/models/microscope.py index 53cbf0ef..10cd93d1 100644 --- a/backend/proteins/models/microscope.py +++ b/backend/proteins/models/microscope.py @@ -44,7 +44,9 @@ class Microscope(OwnedCollection): "Camera", blank=True, related_name="microscopes" ) extra_lasers = ArrayField( - models.PositiveSmallIntegerField(validators=[MinValueValidator(300), MaxValueValidator(1600)]), + models.PositiveSmallIntegerField( + validators=[MinValueValidator(300), MaxValueValidator(1600)] + ), default=list, blank=True, ) @@ -181,7 +183,13 @@ def bs_filters(self): def spectra(self) -> list[Spectrum]: return [ obj.spectrum - for qs in (self.ex_filters, self.em_filters, self.bs_filters, self.lights, self.cameras) + for qs in ( + self.ex_filters, + self.em_filters, + self.bs_filters, + self.lights, + self.cameras, + ) for obj in qs.select_related("spectrum") ] @@ -195,7 +203,9 @@ def invert(sp): def get_optical_configs_list() -> list[dict]: """Fetch optical configs with microscope info in a single optimized query.""" - vals = OpticalConfig.objects.values("id", "name", "comments", "microscope__id", "microscope__name") + vals = OpticalConfig.objects.values( + "id", "name", "comments", "microscope__id", "microscope__name" + ) return [ { @@ -281,7 +291,9 @@ def ex_filters(self): @cached_property def em_filters(self): """all filters that have an emission role""" - return self.filters.filter(filterplacement__path=FilterPlacement.EM, filterplacement__reflects=False) + return self.filters.filter( + filterplacement__path=FilterPlacement.EM, filterplacement__reflects=False + ) @cached_property def bs_filters(self): @@ -290,7 +302,9 @@ def bs_filters(self): @cached_property def ref_em_filters(self): - return self.filters.filter(filterplacement__path=FilterPlacement.EM, filterplacement__reflects=True) + return self.filters.filter( + filterplacement__path=FilterPlacement.EM, filterplacement__reflects=True + ) @cached_property def ex_spectra(self): @@ -331,7 +345,9 @@ def inverted_bs(self): return self.filterplacement_set.filter(path=FilterPlacement.BS, reflects=True) def add_filter(self, filter, path, reflects=False): - return FilterPlacement.objects.create(filter=filter, config=self, reflects=reflects, path=path) + return FilterPlacement.objects.create( + filter=filter, config=self, reflects=reflects, path=path + ) def __repr__(self): fltrs = sorted_ex2em(self.filters.all()) @@ -358,16 +374,21 @@ class FilterPlacement(models.Model): filter_id: int filter: models.ForeignKey[Filter] = models.ForeignKey("Filter", on_delete=models.CASCADE) config_id: int - config: models.ForeignKey[OpticalConfig] = models.ForeignKey("OpticalConfig", on_delete=models.CASCADE) + config: models.ForeignKey[OpticalConfig] = models.ForeignKey( + "OpticalConfig", on_delete=models.CASCADE + ) path = models.CharField(max_length=2, choices=PATH_CHOICES, verbose_name="Ex/Bs/Em Path") # when path == BS, reflects refers to the emission path - reflects = models.BooleanField(default=False, help_text="Filter reflects emission (if BS or EM filter)") + reflects = models.BooleanField( + default=False, help_text="Filter reflects emission (if BS or EM filter)" + ) def __str__(self): return self.__repr__().lstrip("<").rstrip(">") def __repr__(self): - return f"<{self.path.title()} Filter: {self.filter.name}{' (reflecting)' if self.reflects else ''}>" + reflect = " (reflecting)" if self.reflects else "" + return f"<{self.path.title()} Filter: {self.filter.name}{reflect}>" def quick_OC(name, filternames, scope, bs_ex_reflect=True): @@ -404,7 +425,11 @@ def _assign_filt(_f, i, iexact=False): oc.laser = int(_f) oc.save() except ValueError: - filt = Filter.objects.get(part__iexact=_f) if iexact else Filter.objects.get(name__icontains=_f) + filt = ( + Filter.objects.get(part__iexact=_f) + if iexact + else Filter.objects.get(name__icontains=_f) + ) fp = FilterPlacement( filter=filt, config=oc, @@ -430,7 +455,9 @@ def _assign_filt(_f, i, iexact=False): try: _assign_filt(fname, i, True) except ObjectDoesNotExist: - print(f'Filter name "{fname}" returned multiple hits and exact match not found') + print( + f'Filter name "{fname}" returned multiple hits and exact match not found' + ) oc.delete() return None except ObjectDoesNotExist: diff --git a/backend/proteins/models/protein.py b/backend/proteins/models/protein.py index e4933f25..813767fa 100644 --- a/backend/proteins/models/protein.py +++ b/backend/proteins/models/protein.py @@ -149,12 +149,18 @@ class CofactorChoices(models.TextChoices): db_index=True, verbose_name="FPbase ID", ) - name = models.CharField(max_length=128, help_text="Name of the fluorescent protein", db_index=True) - slug = models.SlugField(max_length=64, unique=True, help_text="URL slug for the protein") # for generating urls + name = models.CharField( + max_length=128, help_text="Name of the fluorescent protein", db_index=True + ) + slug = models.SlugField( + max_length=64, unique=True, help_text="URL slug for the protein" + ) # for generating urls base_name = models.CharField(max_length=128) # easily searchable "family" name aliases = ArrayField(models.CharField(max_length=200), blank=True, null=True) chromophore = _NonNullChar(max_length=5, blank=True, default="") - seq_validated = models.BooleanField(default=False, help_text="Sequence has been validated by a moderator") + seq_validated = models.BooleanField( + default=False, help_text="Sequence has been validated by a moderator" + ) # seq must be nullable because of uniqueness contraints seq = SequenceField( unique=True, @@ -310,7 +316,9 @@ def last_approved_version(self): try: version_objects = cast("VersionQuerySet", Version.objects) return ( - version_objects.get_for_object(self).filter(serialized_data__contains='"status": "approved"').first() + version_objects.get_for_object(self) + .filter(serialized_data__contains='"status": "approved"') + .first() ) except Exception: return None @@ -390,7 +398,9 @@ def d3_spectra(self): return json.dumps(spectra) def spectra_img(self, fmt="svg", output=None, **kwargs): - spectra = list(Spectrum.objects.filter(owner_fluor__state__protein=self).exclude(subtype="2p")) + spectra = list( + Spectrum.objects.filter(owner_fluor__state__protein=self).exclude(subtype="2p") + ) title = self.name if kwargs.pop("title", False) else None if kwargs.get("twitter", False): title = self.name @@ -476,7 +486,9 @@ def date_published(self, norm=False): def n_faves(self, norm=False): nf = Favorite.objects.for_model(Protein).filter(target_object_id=self.id).count() if norm: - mx = Counter(Favorite.objects.for_model(Protein).values_list("target_object_id", flat=True)).most_common(1) + mx = Counter( + Favorite.objects.for_model(Protein).values_list("target_object_id", flat=True) + ).most_common(1) mx = mx[0][1] if mx else 1 return nf / mx return nf diff --git a/backend/proteins/models/spectrum.py b/backend/proteins/models/spectrum.py index 46dce771..a14a7239 100644 --- a/backend/proteins/models/spectrum.py +++ b/backend/proteins/models/spectrum.py @@ -356,7 +356,9 @@ class Spectrum(Authorable, StatusModel, TimeStampedModel, AdminURLMixin): } data = SpectrumData() - category = models.CharField(max_length=1, choices=CATEGORIES, verbose_name="Spectrum Type", db_index=True) + category = models.CharField( + max_length=1, choices=CATEGORIES, verbose_name="Spectrum Type", db_index=True + ) subtype = models.CharField( max_length=2, choices=SUBTYPE_CHOICES, @@ -422,7 +424,8 @@ class Spectrum(Authorable, StatusModel, TimeStampedModel, AdminURLMixin): class Meta: verbose_name_plural = "spectra" indexes = [ - # Composite index for the most common query pattern: filtering by fluorophore and status + # Composite index for the most common query pattern: + # filtering by fluorophore and status models.Index(fields=["owner_fluor_id", "status"], name="spectrum_fluor_status_idx"), # Index on status for queries that only filter by approval status models.Index(fields=["status"], name="spectrum_status_idx"), @@ -464,7 +467,10 @@ def clean(self): self.subtype = self.QE if self.category == self.LIGHT: self.subtype = self.PD - if self.category in self.category_subtypes and self.subtype not in self.category_subtypes[self.category]: + if ( + self.category in self.category_subtypes + and self.subtype not in self.category_subtypes[self.category] + ): errors.update( { "subtype": "{} spectrum subtype must be{} {}".format( @@ -527,7 +533,9 @@ def name(self) -> str: def peak_wave(self): try: if self.min_wave < 300: - return self.x[self.y.index(max([i for n, i in enumerate(self.y) if self.x[n] > 300]))] + return self.x[ + self.y.index(max([i for n, i in enumerate(self.y) if self.x[n] > 300])) + ] else: try: # first look for the value 1 @@ -572,7 +580,9 @@ def avg(self, waverange): def width(self, height=0.5) -> tuple[float, float] | None: try: upindex = next(x[0] for x in enumerate(self.y) if x[1] > height) - downindex = len(self.y) - next(x[0] for x in enumerate(reversed(self.y)) if x[1] > height) + downindex = len(self.y) - next( + x[0] for x in enumerate(reversed(self.y)) if x[1] > height + ) return (self.x[upindex], self.x[downindex]) except Exception: return None @@ -683,7 +693,9 @@ class Filter(SpectrumOwner, Product): blank=True, validators=[MinValueValidator(300), MaxValueValidator(1600)], ) - tavg = models.FloatField(blank=True, null=True, validators=[MinValueValidator(0), MaxValueValidator(1)]) + tavg = models.FloatField( + blank=True, null=True, validators=[MinValueValidator(0), MaxValueValidator(1)] + ) aoi = models.PositiveSmallIntegerField( blank=True, null=True, validators=[MinValueValidator(0), MaxValueValidator(90)] ) diff --git a/backend/proteins/models/transition.py b/backend/proteins/models/transition.py index cef8654e..234a5ec8 100644 --- a/backend/proteins/models/transition.py +++ b/backend/proteins/models/transition.py @@ -47,11 +47,15 @@ class StateTransition(Authorable, TimeStampedModel): def clean(self): errors = {} if self.from_state.protein != self.protein: - errors.update({"from_state": f'"From" state must belong to protein {self.protein.name}'}) + errors.update( + {"from_state": f'"From" state must belong to protein {self.protein.name}'} + ) if self.to_state.protein != self.protein: errors.update({"to_state": f'"To" state must belong to protein {self.protein.name}'}) if errors: raise ValidationError(errors) def __str__(self): - return f"{self.protein.name} {self.from_state.name} -{self.trans_wave}-> {self.to_state.name}" + return ( + f"{self.protein.name} {self.from_state.name} -{self.trans_wave}-> {self.to_state.name}" + ) diff --git a/backend/proteins/schema/query.py b/backend/proteins/schema/query.py index 7ab28039..81a9ddba 100644 --- a/backend/proteins/schema/query.py +++ b/backend/proteins/schema/query.py @@ -91,7 +91,9 @@ def resolve_protein(self, info, **kwargs): return None # spectra = graphene.List(Spectrum) - spectra = graphene.List(types.SpectrumInfo, subtype=graphene.String(), category=graphene.String()) + spectra = graphene.List( + types.SpectrumInfo, subtype=graphene.String(), category=graphene.String() + ) spectrum = graphene.Field(types.Spectrum, id=graphene.Int()) def resolve_spectra(self, info, **kwargs): diff --git a/backend/proteins/util/_scipy.py b/backend/proteins/util/_scipy.py index 0405e89f..315c43c3 100644 --- a/backend/proteins/util/_scipy.py +++ b/backend/proteins/util/_scipy.py @@ -168,7 +168,9 @@ def __init__(self, x: np.ndarray, y: np.ndarray): A[i, i - 1] = h[i - 1] A[i, i] = 2 * (h[i - 1] + h[i]) A[i, i + 1] = h[i] - b[i] = 3 * ((self.y[i + 1] - self.y[i]) / h[i] - (self.y[i] - self.y[i - 1]) / h[i - 1]) + b[i] = 3 * ( + (self.y[i + 1] - self.y[i]) / h[i] - (self.y[i] - self.y[i - 1]) / h[i - 1] + ) # Solve for second derivatives self.c = np.linalg.solve(A, b) @@ -208,7 +210,9 @@ def __call__(self, xnew: Any) -> np.ndarray | float: # Compute spline value dx = xi - self.x[j] a = self.y[j] - b_coef = (self.y[j + 1] - self.y[j]) / self.h[j] - self.h[j] * (2 * self.c[j] + self.c[j + 1]) / 3 + b_coef = (self.y[j + 1] - self.y[j]) / self.h[j] - self.h[j] * ( + 2 * self.c[j] + self.c[j + 1] + ) / 3 d_coef = (self.c[j + 1] - self.c[j]) / (3 * self.h[j]) result[idx] = a + b_coef * dx + self.c[j] * dx**2 + d_coef * dx**3 diff --git a/backend/proteins/util/blurb.py b/backend/proteins/util/blurb.py index 59fb82e1..39decbe9 100644 --- a/backend/proteins/util/blurb.py +++ b/backend/proteins/util/blurb.py @@ -72,7 +72,9 @@ def long_blurb(self, withbright=False, withbleach=False): elif M >= 90: mature = "" if mature: - blurb += "It is reported to be a {} {}".format(mature, self.get_agg_display().lower() or "protein") + blurb += "It is reported to be a {} {}".format( + mature, self.get_agg_display().lower() or "protein" + ) acid = None A = self.default_state.pka @@ -97,6 +99,8 @@ def long_blurb(self, withbright=False, withbleach=False): if self.get_agg_display(): blurb += f"It is reported to be a {self.get_agg_display().lower()}." if self.cofactor: - blurb += f" It requires the cofactor {self.get_cofactor_display().lower()} for fluorescence." + blurb += ( + f" It requires the cofactor {self.get_cofactor_display().lower()} for fluorescence." + ) return blurb.strip() diff --git a/backend/proteins/util/efficiency.py b/backend/proteins/util/efficiency.py index 27d2af97..0ed80389 100644 --- a/backend/proteins/util/efficiency.py +++ b/backend/proteins/util/efficiency.py @@ -81,7 +81,9 @@ def oc_efficiency_report(oc, fluor_collection): if fluor.ex_spectrum and oc_ex: combospectrum = spectral_product([oc_ex, fluor.ex_spectrum.data]) D[fluor.slug]["ex"] = round(area(combospectrum) / area(oc_ex), 3) - D[fluor.slug]["ex_broad"] = round(area(combospectrum) / area(fluor.ex_spectrum.data), 3) + D[fluor.slug]["ex_broad"] = round( + area(combospectrum) / area(fluor.ex_spectrum.data), 3 + ) if D[fluor.slug].get("em") and D[fluor.slug].get("ex") and fluor.ext_coeff and fluor.qy: b = D[fluor.slug]["em"] * D[fluor.slug]["ex"] * fluor.ext_coeff * fluor.qy / 1000 D[fluor.slug]["bright"] = round(b, 3) diff --git a/backend/proteins/util/helpers.py b/backend/proteins/util/helpers.py index f7e94729..4f08cfc1 100644 --- a/backend/proteins/util/helpers.py +++ b/backend/proteins/util/helpers.py @@ -144,7 +144,9 @@ def shortuuid(padding=None): def zip_wave_data(waves, data, minmax=None): minmax = minmax or (150, 1800) - return [list(i) for i in zip(waves, data) if (minmax[0] <= i[0] <= minmax[1]) and not isnan(i[1])] + return [ + list(i) for i in zip(waves, data) if (minmax[0] <= i[0] <= minmax[1]) and not isnan(i[1]) + ] def wave_to_hex(wavelength, gamma=1): @@ -295,7 +297,10 @@ def calculate_spectral_overlap(donor, acceptor): A = accEx.wave_value_pairs() D = donEm.wave_value_pairs() - overlap = [(pow(wave, 4) * A[wave] * accEC * D[wave] / donCum) for wave in range(startingwave, endingwave + 1)] + overlap = [ + (pow(wave, 4) * A[wave] * accEC * D[wave] / donCum) + for wave in range(startingwave, endingwave + 1) + ] return sum(overlap) @@ -382,7 +387,9 @@ def forster_list(): "acceptor": "{}{}".format( acceptor.get_absolute_url(), acceptor.name, - f"{acceptor.cofactor.upper()}" if acceptor.cofactor else "", + f"{acceptor.cofactor.upper()}" + if acceptor.cofactor + else "", ), "donorPeak": donor.default_state.ex_max, "acceptorPeak": acceptor.default_state.ex_max, @@ -455,7 +462,13 @@ def spectra_fig( color = colr if colr else spec.color() if fill: alpha = 0.5 if not alph else float(alph) - ax.fill_between(*list(zip(*spec.data)), color=color, alpha=alpha, url="http://google.com=", **kwargs) + ax.fill_between( + *list(zip(*spec.data)), + color=color, + alpha=alpha, + url="http://google.com=", + **kwargs, + ) else: alpha = 1 if not alph else float(alph) ax.plot(*list(zip(*spec.data)), alpha=alpha, color=spec.color(), **kwargs) diff --git a/backend/proteins/util/importers.py b/backend/proteins/util/importers.py index 9be10b5b..159f15c9 100644 --- a/backend/proteins/util/importers.py +++ b/backend/proteins/util/importers.py @@ -132,7 +132,11 @@ def fetch_semrock_part(part): try: url = ( "https://www.semrock.com" - + (str(response.content).split('" title="Click to Download ASCII')[0].split('href="')[-1]) + + ( + str(response.content) + .split('" title="Click to Download ASCII')[0] + .split('href="')[-1] + ) ) except Exception as e: raise ValueError(f"Could not parse page for semrock part: {part}") from e diff --git a/backend/proteins/util/maintain.py b/backend/proteins/util/maintain.py index 0a4cae3f..3f757a66 100644 --- a/backend/proteins/util/maintain.py +++ b/backend/proteins/util/maintain.py @@ -35,7 +35,9 @@ def suggested_switch_type(protein: Protein) -> str | None: return protein.SwitchingChoices.BASIC # 2 or more states... n_transitions = protein.transitions.count() - darkstates = protein.ndark if hasattr(protein, "ndark") else protein.states.filter(is_dark=True).count() + darkstates = ( + protein.ndark if hasattr(protein, "ndark") else protein.states.filter(is_dark=True).count() + ) if not n_transitions: return str(protein.SwitchingChoices.OTHER) elif nstates == 2: diff --git a/backend/proteins/util/spectra.py b/backend/proteins/util/spectra.py index 63f474fb..41dccd63 100644 --- a/backend/proteins/util/spectra.py +++ b/backend/proteins/util/spectra.py @@ -26,7 +26,9 @@ def _make_monotonic(x: Iterable, y: Iterable) -> tuple[NDArray, NDArray]: return x_out, y -def interp_linear(x: ArrayLike, y: ArrayLike, s: int = 1, savgol: bool = False) -> tuple[Iterable, Iterable]: +def interp_linear( + x: ArrayLike, y: ArrayLike, s: int = 1, savgol: bool = False +) -> tuple[Iterable, Iterable]: """Interpolate pair of vectors at integer increments between min(x) and max(x).""" x = np.asarray(x) y = np.asarray(y) diff --git a/backend/proteins/util/spectra_import.py b/backend/proteins/util/spectra_import.py index eb64d113..f675b63a 100644 --- a/backend/proteins/util/spectra_import.py +++ b/backend/proteins/util/spectra_import.py @@ -19,7 +19,9 @@ def get_stype(header): # FIXME: I kind of hate this function... -def import_spectral_data(waves, data, headers=None, categories=(), stypes=(), owner=None, minmax=None): +def import_spectral_data( + waves, data, headers=None, categories=(), stypes=(), owner=None, minmax=None +): """ Take a vector of waves and a matrix of data, and import into database diff --git a/backend/proteins/validators.py b/backend/proteins/validators.py index 82603a3b..564b537f 100644 --- a/backend/proteins/validators.py +++ b/backend/proteins/validators.py @@ -57,7 +57,9 @@ def protein_sequence_validator(seq): badletters = [letter for letter in seq if letter not in IUPAC_PROTEIN_LETTERS] if len(badletters): badletters = set(badletters) - raise ValidationError(f"Invalid letter(s) found in amino acid sequence: {''.join(badletters)}") + raise ValidationError( + f"Invalid letter(s) found in amino acid sequence: {''.join(badletters)}" + ) def validate_spectrum(value): diff --git a/backend/proteins/views/ajax.py b/backend/proteins/views/ajax.py index c54aba2a..8a4ec1f5 100644 --- a/backend/proteins/views/ajax.py +++ b/backend/proteins/views/ajax.py @@ -26,7 +26,9 @@ def serialize_comparison(request): info = [] slugs = request.session.get("comparison", []) # Prefetch states and their spectra to avoid N+1 queries - proteins = Protein.objects.filter(slug__in=slugs).prefetch_related("default_state", "states__spectra") + proteins = Protein.objects.filter(slug__in=slugs).prefetch_related( + "default_state", "states__spectra" + ) for prot in proteins: d = {"name": prot.name, "slug": prot.slug} if prot.default_state: @@ -136,14 +138,21 @@ def similar_spectrum_owners(request): # (Fluorophore.objects returns base class instances without get_absolute_url) from proteins.models.dye import DyeState - states = State.objects.filter(id__in=fluor_ids).select_related("protein").prefetch_related("spectra") - dye_states = DyeState.objects.filter(id__in=fluor_ids).select_related("dye").prefetch_related("spectra") + states = ( + State.objects.filter(id__in=fluor_ids) + .select_related("protein") + .prefetch_related("spectra") + ) + dye_states = ( + DyeState.objects.filter(id__in=fluor_ids).select_related("dye").prefetch_related("spectra") + ) filters = Filter.objects.filter(id__in=filter_ids).select_related("spectrum") lights = Light.objects.filter(id__in=light_ids).select_related("spectrum") cameras = Camera.objects.filter(id__in=camera_ids).select_related("spectrum") # Combine all objects maintaining order - # For Fluorophores (State/DyeState), use ID-only as key since original `similars` has base "FluorState" class + # For Fluorophores (State/DyeState), use ID-only as key since original `similars` has + # base "FluorState" class similars_dict = {} for item in [*states, *dye_states, *filters, *lights, *cameras]: if isinstance(item, FluorState): @@ -242,7 +251,9 @@ def get_lineage(request, slug=None, org=None): else: ids = Lineage.objects.all().values_list("id", flat=True) # cache upfront everything we're going to need - stateprefetch = Prefetch("protein__states", queryset=State.objects.order_by("-is_dark", "em_max")) + stateprefetch = Prefetch( + "protein__states", queryset=State.objects.order_by("-is_dark", "em_max") + ) root_nodes = ( Lineage.objects.filter(id__in=ids) .select_related("protein", "reference", "protein__default_state") diff --git a/backend/proteins/views/autocomplete.py b/backend/proteins/views/autocomplete.py index d2725a0d..31af0d34 100644 --- a/backend/proteins/views/autocomplete.py +++ b/backend/proteins/views/autocomplete.py @@ -18,7 +18,11 @@ def get_results(self, context): ] def get_queryset(self): - qs = Protein.objects.with_spectra() if self.request.GET.get("type", "") == "spectra" else Protein.objects.all() + qs = ( + Protein.objects.with_spectra() + if self.request.GET.get("type", "") == "spectra" + else Protein.objects.all() + ) if self.q: qs = qs.filter(name__icontains=self.q) return qs diff --git a/backend/proteins/views/collection.py b/backend/proteins/views/collection.py index 30636a46..90ae9b74 100644 --- a/backend/proteins/views/collection.py +++ b/backend/proteins/views/collection.py @@ -39,7 +39,9 @@ def serialized_proteins_response(queryset, format="json", filename="FPbase_prote from django.http import StreamingHttpResponse from rest_framework_csv.renderers import CSVStreamingRenderer - response = StreamingHttpResponse(CSVStreamingRenderer().render(serializer.data), content_type="text/csv") + response = StreamingHttpResponse( + CSVStreamingRenderer().render(serializer.data), content_type="text/csv" + ) response["Content-Disposition"] = f'attachment; filename="{filename}.csv"' elif format == "json": response = JsonResponse(serializer.data, safe=False) @@ -89,7 +91,9 @@ def get(self, request, *args, **kwargs): fmt = request.GET.get("format", "").lower() if fmt in ("json", "csv"): col = self.get_object() - return serialized_proteins_response(col.proteins.all(), fmt, filename=slugify(col.name)) + return serialized_proteins_response( + col.proteins.all(), fmt, filename=slugify(col.name) + ) return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): @@ -106,7 +110,11 @@ def get_context_data(self, **kwargs): return context def render_to_response(self, *args, **kwargs): - if not self.request.user.is_superuser and self.object.private and (self.object.owner != self.request.user): + if ( + not self.request.user.is_superuser + and self.object.private + and (self.object.owner != self.request.user) + ): return render(self.request, "proteins/private_collection.html", {"foo": "bar"}) return super().render_to_response(*args, **kwargs) @@ -136,7 +144,9 @@ def add_to_collection(request): if request.method == "GET": qs = ProteinCollection.objects.filter(owner=request.user) - widget = forms.Select(attrs={"class": "form-control form-select", "id": "collectionSelect"}) + widget = forms.Select( + attrs={"class": "form-control form-select", "id": "collectionSelect"} + ) choicefield = forms.ChoiceField(choices=qs.values_list("id", "name"), widget=widget) members = [] @@ -177,7 +187,9 @@ def get_form_kwargs(self): # be used to make a new collection elif self.request.POST.get("dupcollection", False): id = self.request.POST.get("dupcollection") - kwargs["proteins"] = [p.id for p in ProteinCollection.objects.get(id=id).proteins.all()] + kwargs["proteins"] = [ + p.id for p in ProteinCollection.objects.get(id=id).proteins.all() + ] return kwargs def form_valid(self, form): diff --git a/backend/proteins/views/microscope.py b/backend/proteins/views/microscope.py index d529ace5..6068c27b 100644 --- a/backend/proteins/views/microscope.py +++ b/backend/proteins/views/microscope.py @@ -54,7 +54,9 @@ def update_scope_report(request): if active: for _worker, jobs in active.items(): for job in jobs: - if job["name"].endswith("calculate_scope_report") and (scope_id in job["args"]): + if job["name"].endswith("calculate_scope_report") and ( + scope_id in job["args"] + ): return JsonResponse({"status": 200, "job": job["id"]}) if len(jobs) >= 4: return JsonResponse({"status": 200, "job": None, "waiting": True}) @@ -203,11 +205,15 @@ def post(self, request, *args, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - probe_count = State.objects.with_spectra().count() + DyeState.objects.with_spectra().count() + probe_count = ( + State.objects.with_spectra().count() + DyeState.objects.with_spectra().count() + ) ids = self.object.optical_configs.all().values_list("id", flat=True) effs = OcFluorEff.objects.filter(oc__in=ids) context["outdated"] = list(effs.outdated().values_list("id", flat=True)) - context["needs_update"] = bool(context["outdated"]) or (probe_count * len(ids) > effs.count()) + context["needs_update"] = bool(context["outdated"]) or ( + probe_count * len(ids) > effs.count() + ) cols = ( ProteinCollection.objects.filter(Q(private=False) | Q(owner_id=self.request.user.id)) .exclude(proteins=None) @@ -239,7 +245,9 @@ def form_valid(self, form): return self.render_to_response(context) # enforce at least one valid optical config - ocform_has_forms = any(f.cleaned_data.get("name") for f in ocformset.forms if not f.cleaned_data.get("DELETE")) + ocform_has_forms = any( + f.cleaned_data.get("name") for f in ocformset.forms if not f.cleaned_data.get("DELETE") + ) if not (ocform_has_forms or form.cleaned_data.get("optical_configs")): messages.add_message( self.request, @@ -298,7 +306,9 @@ def get_context_data(self, **kwargs): return data -class MicroscopeUpdateView(SuccessMessageMixin, MicroscopeCreateUpdateMixin, OwnableObject, UpdateView): +class MicroscopeUpdateView( + SuccessMessageMixin, MicroscopeCreateUpdateMixin, OwnableObject, UpdateView +): model = Microscope form_class = MicroscopeForm success_message = "Update successful!" @@ -317,7 +327,9 @@ def get_context_data(self, **kwargs): if self.request.POST: data["optical_configs"] = OpticalConfigFormSet(self.request.POST, instance=self.object) else: - data["optical_configs"] = OpticalConfigFormSet(instance=self.object, queryset=OpticalConfig.objects.all()) + data["optical_configs"] = OpticalConfigFormSet( + instance=self.object, queryset=OpticalConfig.objects.all() + ) return data @@ -362,7 +374,10 @@ def get_context_data(self, **kwargs): if self.object.collection: proteins = self.object.collection.proteins.with_spectra().prefetch_related("states") data["probeslugs"] = [ - {"slug": s.slug, "name": str(s)} for p in proteins for s in p.states.all() if s.spectra + {"slug": s.slug, "name": str(s)} + for p in proteins + for s in p.states.all() + if s.spectra ] else: data["probeslugs"] = Spectrum.objects.fluorlist() @@ -421,7 +436,9 @@ def get_context_data(self, **kwargs): context["owner"] = owner context["example_list"] = Microscope.objects.filter(id__in=self.example_ids) if self.request.user.is_authenticated: - context["managing"] = Microscope.objects.filter(managers__contains=[self.request.user.email]) + context["managing"] = Microscope.objects.filter( + managers__contains=[self.request.user.email] + ) return context diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index 84082341..726fbdb0 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -139,7 +139,11 @@ def get_country_code(request) -> str: """Get country code from IP address using cached API lookup.""" x_forwarded_for = request.headers.get("x-forwarded-for") - ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else request.META.get("REMOTE_ADDR") + ip = ( + x_forwarded_for.split(",")[0].strip() + if x_forwarded_for + else request.META.get("REMOTE_ADDR") + ) try: if not (masked_ip := _mask_ip_for_caching(ip)): @@ -205,7 +209,11 @@ def get_object(self, queryset=None): obj = queryset.get(uuid=self.kwargs.get("slug", "").upper()) except Protein.DoesNotExist as e: raise Http404("No protein found matching this query") from e - if obj.status == "hidden" and obj.created_by != self.request.user and not self.request.user.is_staff: + if ( + obj.status == "hidden" + and obj.created_by != self.request.user + and not self.request.user.is_staff + ): raise Http404("No protein found matching this query") return obj @@ -246,7 +254,8 @@ def get(self, request, *args, **kwargs): messages.add_message( self.request, messages.INFO, - f"The URL {self.request.get_full_path()} was not found. You have been forwarded here", + f"The URL {self.request.get_full_path()} was not found. " + "You have been forwarded here", ) return HttpResponseRedirect(obj.get_absolute_url()) raise @@ -259,7 +268,9 @@ def get_context_data(self, **kwargs): similar = Protein.visible.filter(name__iexact=f"m{self.object.name}") similar = similar | Protein.visible.filter(name__iexact=f"monomeric{self.object.name}") similar = similar | Protein.visible.filter(name__iexact=self.object.name.lstrip("m")) - similar = similar | Protein.visible.filter(name__iexact=self.object.name.lower().lstrip("td")) + similar = similar | Protein.visible.filter( + name__iexact=self.object.name.lower().lstrip("td") + ) similar = similar | Protein.visible.filter(name__iexact=f"td{self.object.name}") data["similar"] = similar.exclude(id=self.object.id) spectra = [sp for state in self.object.states.all() for sp in state.spectra.all()] @@ -268,7 +279,9 @@ def get_context_data(self, **kwargs): data["hidden_spectra"] = ",".join([str(sp.id) for sp in spectra if sp.subtype in ("2p")]) # put links in excerpts - data["excerpts"] = link_excerpts(self.object.excerpts.all(), self.object.name, self.object.aliases) + data["excerpts"] = link_excerpts( + self.object.excerpts.all(), self.object.name, self.object.aliases + ) # Add country code to context try: @@ -352,10 +365,14 @@ def form_valid(self, form: ProteinForm): if hasattr(self.object, "lineage"): if not self.object.seq: - seq = self.object.lineage.parent.protein.seq.mutate(self.object.lineage.mutation) + seq = self.object.lineage.parent.protein.seq.mutate( + self.object.lineage.mutation + ) self.object.seq = str(seq) if not self.object.parent_organism: - self.object.parent_organism = self.object.lineage.root_node.protein.parent_organism + self.object.parent_organism = ( + self.object.lineage.root_node.protein.parent_organism + ) comment = f"{self.object} {self.get_form_type()} form." chg_string = "\n".join(get_form_changes(form, states, lineage)) @@ -437,7 +454,11 @@ def get_object(self, queryset=None): obj = queryset.get(uuid=self.kwargs.get("slug", "").upper()) except Protein.DoesNotExist as e: raise Http404("No protein found matching this query") from e - if obj.status == "hidden" and obj.created_by != self.request.user and not self.request.user.is_staff: + if ( + obj.status == "hidden" + and obj.created_by != self.request.user + and not self.request.user.is_staff + ): raise Http404("No protein found matching this query") return obj @@ -464,7 +485,9 @@ class ActivityView(ListView): "states", queryset=State.objects.prefetch_related("spectra").order_by("-is_dark", "em_max"), ) - queryset = Protein.visible.prefetch_related(stateprefetch, "primary_reference").order_by("-created")[:18] + queryset = Protein.visible.prefetch_related(stateprefetch, "primary_reference").order_by( + "-created" + )[:18] def get_context_data(self, **kwargs): data = super().get_context_data(**kwargs) @@ -535,7 +558,11 @@ def get_context_data(self, **kwargs): # the page was requested with slugs in the URL, maintain that order ids = kwargs.get("proteins", "").split(",") p = Case(*[When(slug=slug, then=pos) for pos, slug in enumerate(ids)]) - prots = Protein.objects.filter(slug__in=ids).prefetch_related("states__spectra").order_by(p) + prots = ( + Protein.objects.filter(slug__in=ids) + .prefetch_related("states__spectra") + .order_by(p) + ) else: # try to use chronological order ids = self.request.session.get("comparison", []) @@ -563,7 +590,9 @@ def get_context_data(self, **kwargs): head = prots[0].name elif i % 2 == 0: head = prots[1].name - out.append("{:<18.16}{}".format(head if len(head) < 17 else head[:13] + "...", row)) + out.append( + "{:<18.16}{}".format(head if len(head) < 17 else head[:13] + "...", row) + ) out.append("\n") context["alignment"] = "\n".join(out) context["mutations"] = str(seq_a.seq.mutations_to(seq_b.seq)) @@ -580,7 +609,9 @@ def get_context_data(self, **kwargs): for state in prot.states.all(): spectra.extend(state.spectra.all()) context["spectra_ids"] = ",".join([str(sp.id) for sp in spectra]) - context["hidden_spectra"] = ",".join([str(sp.id) for sp in spectra if sp.subtype in ("2p")]) + context["hidden_spectra"] = ",".join( + [str(sp.id) for sp in spectra if sp.subtype in ("2p")] + ) return context @@ -599,7 +630,9 @@ def problems_gaps(request): .distinct("protein") .values("protein__name", "protein__slug") ), - "nolineage": Protein.objects.filter(lineage=None).annotate(ns=Count("states__spectra")).order_by("-ns"), + "nolineage": Protein.objects.filter(lineage=None) + .annotate(ns=Count("states__spectra")) + .order_by("-ns"), "request": request, }, ) @@ -608,7 +641,10 @@ def problems_gaps(request): def problems_inconsistencies(request): titles = reduce( operator.or_, - (Q(primary_reference__title__icontains=item) for item in ["activat", "switch", "convert", "dark", "revers"]), + ( + Q(primary_reference__title__icontains=item) + for item in ["activat", "switch", "convert", "dark", "revers"] + ), ) names = reduce( operator.or_, @@ -619,7 +655,11 @@ def problems_inconsistencies(request): switchers = switchers.annotate(ns=Count("states")).filter(ns=1) gb_mismatch = [] - with_genbank = Protein.objects.exclude(genbank=None).exclude(seq=None).values("slug", "name", "genbank", "seq") + with_genbank = ( + Protein.objects.exclude(genbank=None) + .exclude(seq=None) + .values("slug", "name", "genbank", "seq") + ) gbseqs = get_cached_gbseqs([g["genbank"] for g in with_genbank]) for item in with_genbank: if item["genbank"] in gbseqs: @@ -671,7 +711,8 @@ def add_reference(request, slug=None): if not request.user.is_staff: p.status = "pending" msg = ( - f"User: {request.user.username}\nProtein: {p}\nReference: {ref}. {ref.title}\n\n" + f"User: {request.user.username}\nProtein: {p}\n" + f"Reference: {ref}. {ref.title}\n\n" f"{request.build_absolute_uri(p.get_absolute_url())}" ) mail_managers("Reference Added", msg, fail_silently=True) @@ -696,11 +737,14 @@ def add_protein_excerpt(request, slug=None): if content: ref, _created = Reference.objects.get_or_create(doi=doi) p.references.add(ref) - excerpt = Excerpt.objects.create(reference=ref, content=strip_tags(content), created_by=request.user) + excerpt = Excerpt.objects.create( + reference=ref, content=strip_tags(content), created_by=request.user + ) excerpt.proteins.add(p) if not request.user.is_staff: msg = ( - f"User: {request.user.username}\nProtein: {p}\nReference: {ref}, {ref.title}\nExcerpt: " + f"User: {request.user.username}\nProtein: {p}\n" + f"Reference: {ref}, {ref.title}\nExcerpt: " f"{strip_tags(content)}\n{request.build_absolute_uri(p.get_absolute_url())}" ) mail_managers("Excerpt Added", msg, fail_silently=True) @@ -730,7 +774,9 @@ def revert_revision(request, rev=None): with transaction.atomic(): revision.revert(delete=True) - proteins = {v.object for v in revision.version_set.all() if v.object._meta.model_name == "protein"} + proteins = { + v.object for v in revision.version_set.all() if v.object._meta.model_name == "protein" + } if len(proteins) == 1: p = proteins.pop() with reversion.create_revision(): @@ -782,7 +828,9 @@ def update_transitions(request, slug=None): @login_required def protein_bleach_formsets(request, slug): template_name = "proteins/protein_bleach_form.html" - BleachMeasurementFormSet = modelformset_factory(BleachMeasurement, BleachMeasurementForm, extra=1, can_delete=True) + BleachMeasurementFormSet = modelformset_factory( + BleachMeasurement, BleachMeasurementForm, extra=1, can_delete=True + ) protein = get_object_or_404(Protein, slug=slug) qs = BleachMeasurement.objects.filter(state__protein=protein) if request.method == "POST": diff --git a/backend/proteins/views/search.py b/backend/proteins/views/search.py index 267cc5fd..af2c8100 100644 --- a/backend/proteins/views/search.py +++ b/backend/proteins/views/search.py @@ -34,7 +34,11 @@ def protein_search(request): except Protein.DoesNotExist: pass try: - page = Author.objects.filter(family__iexact=query).annotate(nr=Count("publications")).order_by("-nr") + page = ( + Author.objects.filter(family__iexact=query) + .annotate(nr=Count("publications")) + .order_by("-nr") + ) if page: return redirect(page.first()) except Author.DoesNotExist: @@ -62,7 +66,8 @@ def protein_search(request): return redirect("/search/?name__iexact=" + query) stateprefetch = Prefetch( - "states", queryset=State.objects.order_by("-is_dark", "em_max").prefetch_related("spectra") + "states", + queryset=State.objects.order_by("-is_dark", "em_max").prefetch_related("spectra"), ) f = ProteinFilter( request.GET, @@ -81,7 +86,9 @@ def protein_search(request): name = f.form.data["name__iexact"] if name: f.recs = ( - Protein.visible.annotate(nstates=Count("states"), similarity=TrigramSimilarity("name", name)) + Protein.visible.annotate( + nstates=Count("states"), similarity=TrigramSimilarity("name", name) + ) .filter(similarity__gt=0.2) .select_related("default_state", "primary_reference") .prefetch_related(stateprefetch, "transitions") diff --git a/backend/proteins/views/spectra.py b/backend/proteins/views/spectra.py index 7ccc4367..aca339f0 100644 --- a/backend/proteins/views/spectra.py +++ b/backend/proteins/views/spectra.py @@ -213,7 +213,9 @@ def spectrum_preview(request) -> JsonResponse: return JsonResponse( { "error": f"Data processing failed: {e!s}", - "details": "The spectrum data could not be normalized. Please check your data format.", + "details": ( + "The spectrum data could not be normalized. Please check your data format." + ), }, status=400, ) @@ -347,7 +349,9 @@ def pending_spectra_dashboard(request): svg_preview = None if spectrum.data is not None: try: - buffer = spectrum.spectrum_img(fmt="svg", xlabels=True, ylabels=True, figsize=(10, 3)) + buffer = spectrum.spectrum_img( + fmt="svg", xlabels=True, ylabels=True, figsize=(10, 3) + ) svg_preview = buffer.getvalue().decode("utf-8") except Exception: svg_preview = None @@ -386,13 +390,17 @@ def pending_spectrum_action(request): action = request.POST.get("action") if not spectrum_ids or not action: - return JsonResponse({"success": False, "error": "Missing spectrum_ids or action"}, status=400) + return JsonResponse( + {"success": False, "error": "Missing spectrum_ids or action"}, status=400 + ) # For revert (undo), we need to find spectra regardless of status if action == "revert": spectra = Spectrum.objects.all_objects().filter(id__in=spectrum_ids) if not spectra.exists(): - return JsonResponse({"success": False, "error": "No spectra found with provided IDs"}, status=404) + return JsonResponse( + {"success": False, "error": "No spectra found with provided IDs"}, status=404 + ) count = spectra.count() spectra.update(status=Spectrum.STATUS.pending) message = f"Reverted {count} spectrum(s) to pending" @@ -406,7 +414,8 @@ def pending_spectrum_action(request): if not spectra.exists(): return JsonResponse( - {"success": False, "error": "No pending spectra found with provided IDs"}, status=404 + {"success": False, "error": "No pending spectra found with provided IDs"}, + status=404, ) count = spectra.count() @@ -430,7 +439,9 @@ def pending_spectrum_action(request): message = f"Deleted {count} spectrum(s)" else: - return JsonResponse({"success": False, "error": f"Unknown action: {action}"}, status=400) + return JsonResponse( + {"success": False, "error": f"Unknown action: {action}"}, status=400 + ) return JsonResponse({"success": True, "message": message, "count": count}) diff --git a/backend/references/models.py b/backend/references/models.py index 514de6a0..5b73849a 100644 --- a/backend/references/models.py +++ b/backend/references/models.py @@ -60,7 +60,11 @@ def first_authorships(self): @property def last_authorships(self): - return [p.reference for p in self.referenceauthor_set.all() if p.author_idx == p.author_count - 1] + return [ + p.reference + for p in self.referenceauthor_set.all() + if p.author_idx == p.author_count - 1 + ] def save(self, *args, **kwargs): self.initials = _name_to_initials(self.initials) @@ -93,7 +97,9 @@ class Reference(TimeStampedModel): verbose_name="DOI", validators=[validate_doi], ) - pmid = models.CharField(max_length=15, unique=True, null=True, blank=True, verbose_name="Pubmed ID") + pmid = models.CharField( + max_length=15, unique=True, null=True, blank=True, verbose_name="Pubmed ID" + ) title = models.CharField(max_length=512, blank=True) journal = models.CharField(max_length=512, blank=True) pages = models.CharField(max_length=20, blank=True) @@ -112,7 +118,9 @@ class Reference(TimeStampedModel): help_text="YYYY", ) - authors: models.ManyToManyField[Author, Reference] = models.ManyToManyField("Author", through="ReferenceAuthor") + authors: models.ManyToManyField[Author, Reference] = models.ManyToManyField( + "Author", through="ReferenceAuthor" + ) summary = models.CharField(max_length=512, blank=True, help_text="Brief summary of findings") created_by_id: int | None @@ -235,7 +243,9 @@ def url(self): class ReferenceAuthor(models.Model): reference_id: int - reference: models.ForeignKey[Reference] = models.ForeignKey(Reference, on_delete=models.CASCADE) + reference: models.ForeignKey[Reference] = models.ForeignKey( + Reference, on_delete=models.CASCADE + ) author_id: int author: models.ForeignKey[Author] = models.ForeignKey(Author, on_delete=models.CASCADE) author_idx = models.PositiveSmallIntegerField() diff --git a/backend/references/views.py b/backend/references/views.py index 5833c5bc..fb4ffe50 100644 --- a/backend/references/views.py +++ b/backend/references/views.py @@ -83,11 +83,14 @@ def add_excerpt(request, pk=None): content = request.POST.get("excerpt_content") if content: # P.references.add(ref) - Excerpt.objects.create(reference=ref, content=strip_tags(content), created_by=request.user) + Excerpt.objects.create( + reference=ref, content=strip_tags(content), created_by=request.user + ) if not request.user.is_staff: msg = ( f"User: {request.user.username}\nReference: {ref}, {ref.title}" - f"\nExcerpt: {strip_tags(content)}\n{request.build_absolute_uri(ref.get_absolute_url())}" + f"\nExcerpt: {strip_tags(content)}\n" + f"{request.build_absolute_uri(ref.get_absolute_url())}" ) mail_managers("Excerpt Added", msg, fail_silently=True) reversion.set_user(request.user) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4152ec1f..1a6d792e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,7 +17,9 @@ def _mock_django_vite_for_unit_tests(): """ # Mock the generate_vite_asset method on the DjangoViteAssetLoader - with unittest.mock.patch("django_vite.templatetags.django_vite.DjangoViteAssetLoader.instance") as mock_loader: + with unittest.mock.patch( + "django_vite.templatetags.django_vite.DjangoViteAssetLoader.instance" + ) as mock_loader: mock_instance = unittest.mock.MagicMock() mock_instance.generate_vite_asset.return_value = "" mock_loader.return_value = mock_instance diff --git a/backend/tests/test_api/test_graphql_schema.py b/backend/tests/test_api/test_graphql_schema.py index 36cd4490..36854dd2 100644 --- a/backend/tests/test_api/test_graphql_schema.py +++ b/backend/tests/test_api/test_graphql_schema.py @@ -105,7 +105,9 @@ def query(self, query, op_name=None, input_data=None, variables=None): body["variables"]["input"] = input_data else: body["variables"] = {"input": input_data} - return self.client.post(self.GRAPHQL_URL, json.dumps(body), content_type="application/json") + return self.client.post( + self.GRAPHQL_URL, json.dumps(body), content_type="application/json" + ) def test_optical_configs(self): response = self.query( diff --git a/backend/tests/test_favit/test_favit.py b/backend/tests/test_favit/test_favit.py index 3e533abc..bf9966cb 100644 --- a/backend/tests/test_favit/test_favit.py +++ b/backend/tests/test_favit/test_favit.py @@ -69,7 +69,9 @@ def test_get_favorite_returns_none_when_not_exists(self): def test_get_favorite_returns_favorite_when_exists(self): """Test that get_favorite returns favorite when it exists.""" created_fav = Favorite.objects.create(self.user, self.protein.id, "proteins.Protein") - retrieved_fav = Favorite.objects.get_favorite(self.user, self.protein.id, "proteins.Protein") + retrieved_fav = Favorite.objects.get_favorite( + self.user, self.protein.id, "proteins.Protein" + ) assert retrieved_fav is not None assert retrieved_fav.id == created_fav.id diff --git a/backend/tests/test_fpbase/test_query_optimization.py b/backend/tests/test_fpbase/test_query_optimization.py index 7d29df21..379a498b 100644 --- a/backend/tests/test_fpbase/test_query_optimization.py +++ b/backend/tests/test_fpbase/test_query_optimization.py @@ -75,7 +75,9 @@ def setUp(self): # Mock the Organism save to avoid calling Entrez API with patch.object( - models.Organism, "save", lambda self, *args, **kwargs: super(models.Organism, self).save(*args, **kwargs) + models.Organism, + "save", + lambda self, *args, **kwargs: super(models.Organism, self).save(*args, **kwargs), ): # Create organisms using specific IDs from factories self.org1 = factories.OrganismFactory( @@ -132,7 +134,9 @@ def query(self, query_string, op_name=None, variables=None): body["operation_name"] = op_name if variables: body["variables"] = variables - return self.client.post(self.GRAPHQL_URL, json.dumps(body), content_type="application/json") + return self.client.post( + self.GRAPHQL_URL, json.dumps(body), content_type="application/json" + ) def test_proteins_query_is_optimized(self): """ @@ -198,7 +202,9 @@ def test_proteins_query_is_optimized(self): f"Expected optimized query count (<20), but got {num_queries} queries. " f"This suggests N+1 query problem is not being solved.\n" f"Queries executed:\n" - + "\n".join(f"{i + 1}. {q['sql'][:200]}..." for i, q in enumerate(context.captured_queries)), + + "\n".join( + f"{i + 1}. {q['sql'][:200]}..." for i, q in enumerate(context.captured_queries) + ), ) def test_single_protein_with_transitions(self): diff --git a/backend/tests/test_fpbase/test_rate_limiting.py b/backend/tests/test_fpbase/test_rate_limiting.py index cb7a00c7..870616c8 100644 --- a/backend/tests/test_fpbase/test_rate_limiting.py +++ b/backend/tests/test_fpbase/test_rate_limiting.py @@ -28,7 +28,9 @@ def test_graphql_rate_limiting(monkeypatch): client = Client() query = '{"query": "{ __typename }"}' - responses = [client.post("/graphql/", query, content_type="application/json") for _ in range(35)] + responses = [ + client.post("/graphql/", query, content_type="application/json") for _ in range(35) + ] throttled = [r for r in responses if r.status_code == 429] assert len(throttled) >= 5, f"Expected at least 5 throttled requests, got {len(throttled)}" @@ -45,7 +47,9 @@ def test_same_origin_exempt_throttle(monkeypatch): ) # Configure throttle rates - monkeypatch.setitem(SameOriginExemptAnonThrottle.THROTTLE_RATES, "anon", "5/min") # Low limit for testing + monkeypatch.setitem( + SameOriginExemptAnonThrottle.THROTTLE_RATES, "anon", "5/min" + ) # Low limit for testing monkeypatch.setitem(UserRateThrottle.THROTTLE_RATES, "user", "300/min") # Clear cache to ensure clean slate @@ -55,7 +59,9 @@ def test_same_origin_exempt_throttle(monkeypatch): query = '{"query": "{ __typename }"}' # Test 1: Requests WITHOUT referer should be throttled - responses_no_referer = [client.post("/graphql/", query, content_type="application/json") for _ in range(10)] + responses_no_referer = [ + client.post("/graphql/", query, content_type="application/json") for _ in range(10) + ] throttled_no_referer = [r for r in responses_no_referer if r.status_code == 429] assert len(throttled_no_referer) >= 5, ( f"Expected at least 5 throttled requests without referer, got {len(throttled_no_referer)}" @@ -77,7 +83,8 @@ def test_same_origin_exempt_throttle(monkeypatch): ] throttled_with_referer = [r for r in responses_with_referer if r.status_code == 429] assert len(throttled_with_referer) == 0, ( - f"Expected 0 throttled requests with same-origin referer, got {len(throttled_with_referer)}" + f"Expected 0 throttled requests with same-origin referer, " + f"got {len(throttled_with_referer)}" ) # Clear cache for next test diff --git a/backend/tests/test_fpbase/test_templatetags.py b/backend/tests/test_fpbase/test_templatetags.py index a499ad79..a85227b3 100644 --- a/backend/tests/test_fpbase/test_templatetags.py +++ b/backend/tests/test_fpbase/test_templatetags.py @@ -45,7 +45,10 @@ def test_icon_with_aria_hidden(self): def test_icon_multiple_attributes(self): """Test icon rendering with multiple attributes.""" - t = Template('{% load fpbase_tags %}{% icon "warning" class_="me-2" style="color: red;" aria_hidden="true" %}') + t = Template( + "{% load fpbase_tags %}" + '{% icon "warning" class_="me-2" style="color: red;" aria_hidden="true" %}' + ) html = t.render(Context({})) assert 'class="me-2' in html assert 'style="color: red;"' in html @@ -195,8 +198,9 @@ def test_all_template_icons_are_valid(self): # If there are invalid icons, create a helpful error message if invalid_icons: error_lines = [ - "Found icon tags with invalid icon names." - "Either fix the template or add it to ICON_MAP in scripts/extract_fa_icons.py and re-run it." + "Found icon tags with invalid icon names. " + "Either fix the template or add it to ICON_MAP in " + "scripts/extract_fa_icons.py and re-run it." ] for item in invalid_icons: error_lines.append(f" - '{item['icon']}' used in {item['file']}:{item['line']}") @@ -204,5 +208,6 @@ def test_all_template_icons_are_valid(self): # Also verify we found at least some icon usages (sanity check) assert len(icon_usages) > 0, ( - "No {% icon %} tags found in templates. This test may not be searching the correct directory." + "No {% icon %} tags found in templates. " + "This test may not be searching the correct directory." ) diff --git a/backend/tests/test_proteins/test_ajax_views.py b/backend/tests/test_proteins/test_ajax_views.py index 59ba3ece..073b565b 100644 --- a/backend/tests/test_proteins/test_ajax_views.py +++ b/backend/tests/test_proteins/test_ajax_views.py @@ -285,7 +285,8 @@ def test_similar_spectrum_owners_dye_uses_own_name(self): data = response.json() self.assertGreater(len(data["similars"]), 0) - # At least one should be the dye we searched for (label returns dye name when state name is "default") + # At least one should be the dye we searched for + # (label returns dye name when state name is "default") names = [s["name"] for s in data["similars"]] self.assertIn(self.dyes[0].name, names) diff --git a/backend/tests/test_proteins/test_forms.py b/backend/tests/test_proteins/test_forms.py index 791d7a9e..87cb80e6 100644 --- a/backend/tests/test_proteins/test_forms.py +++ b/backend/tests/test_proteins/test_forms.py @@ -45,21 +45,27 @@ def test_clean_proteinname_exists(self): self.assertTrue("name" in form.errors) def test_clean_proteinseq_success(self): - form = ProteinForm({"name": "New Protein", "seq": "ghilkmfpstwy varndceq", "confirmation": True}) + form = ProteinForm( + {"name": "New Protein", "seq": "ghilkmfpstwy varndceq", "confirmation": True} + ) valid = form.is_valid() self.assertTrue(valid, "Form is not valid") seq = form.clean_seq() self.assertEqual("GHILKMFPSTWYVARNDCEQ", seq) def test_clean_proteinseq_exists(self): - form = ProteinForm({"name": "New Protein", "seq": "ARNDCEQGHILKMFPSTWYV", "confirmation": True}) + form = ProteinForm( + {"name": "New Protein", "seq": "ARNDCEQGHILKMFPSTWYV", "confirmation": True} + ) valid = form.is_valid() self.assertFalse(valid) self.assertTrue(len(form.errors) == 1) self.assertTrue("seq" in form.errors) def test_clean_proteinseq_invalid(self): - form = ProteinForm({"name": "New Protein", "seq": "ARNDCEQGHILKMBZXFPSTWYV", "confirmation": True}) + form = ProteinForm( + {"name": "New Protein", "seq": "ARNDCEQGHILKMBZXFPSTWYV", "confirmation": True} + ) valid = form.is_valid() self.assertFalse(valid) self.assertTrue(len(form.errors) == 1) @@ -115,7 +121,9 @@ def setUp(self): State.objects.get_or_create(protein=self.t) def test_clean_state_success(self): - form = StateForm({"name": "default", "ex_max": "488", "em_max": "525", "protein": self.t.id}) + form = StateForm( + {"name": "default", "ex_max": "488", "em_max": "525", "protein": self.t.id} + ) valid = form.is_valid() self.assertTrue(valid, "Form is not valid") self.assertEqual("default", form.cleaned_data["name"]) @@ -274,7 +282,10 @@ def test_spectrum_form_manual_data_missing(self): def test_spectrum_form_file_data_valid(self): """Test form validation with valid file upload and data_source=file""" # Create a mock CSV file with consecutive wavelengths for step size = 1 - file_content = b"400,0.1\n401,0.2\n402,0.3\n403,0.5\n404,0.8\n405,1.0\n406,0.8\n407,0.5\n408,0.3\n409,0.1" + file_content = ( + b"400,0.1\n401,0.2\n402,0.3\n403,0.5\n404,0.8\n" + b"405,1.0\n406,0.8\n407,0.5\n408,0.3\n409,0.1" + ) uploaded_file = SimpleUploadedFile("spectrum.csv", file_content, content_type="text/csv") form_data = { @@ -330,7 +341,10 @@ def test_spectrum_form_with_interpolation(self): "category": Spectrum.PROTEIN, "subtype": Spectrum.EX, "owner_fluor": self.state.id, - "data": ("[[400, 0.1], [405, 0.5], [410, 1.0], [415, 0.8], [420, 0.5], [425, 0.3], [430, 0.1]]"), + "data": ( + "[[400, 0.1], [405, 0.5], [410, 1.0], [415, 0.8]," + " [420, 0.5], [425, 0.3], [430, 0.1]]" + ), "data_source": "manual", "confirmation": True, } diff --git a/backend/tests/test_proteins/test_importers.py b/backend/tests/test_proteins/test_importers.py index 8cf31551..96480c60 100644 --- a/backend/tests/test_proteins/test_importers.py +++ b/backend/tests/test_proteins/test_importers.py @@ -132,10 +132,15 @@ def test_empty_lines(self): def test_actual_production_format(self): """Test exact format from production test suite.""" - csv = "400,0.1\n401,0.2\n402,0.3\n403,0.5\n404,0.8\n405,1.0\n406,0.8\n407,0.5\n408,0.3\n409,0.1" + csv = ( + "400,0.1\n401,0.2\n402,0.3\n403,0.5\n404,0.8\n" + "405,1.0\n406,0.8\n407,0.5\n408,0.3\n409,0.1" + ) waves, data, _headers = text_to_spectra(csv) - self.assertEqual(waves, [400.0, 401.0, 402.0, 403.0, 404.0, 405.0, 406.0, 407.0, 408.0, 409.0]) + self.assertEqual( + waves, [400.0, 401.0, 402.0, 403.0, 404.0, 405.0, 406.0, 407.0, 408.0, 409.0] + ) self.assertEqual(len(data[0]), 10) self.assertEqual(data[0][0], 0.1) self.assertEqual(data[0][-1], 0.1) diff --git a/backend/tests/test_proteins/test_lineage.py b/backend/tests/test_proteins/test_lineage.py index 31ba23df..5c88e313 100644 --- a/backend/tests/test_proteins/test_lineage.py +++ b/backend/tests/test_proteins/test_lineage.py @@ -40,7 +40,9 @@ def test_child_lineage_tree_structure(self): # Create grandchild grandchild_protein = Protein.objects.create(name="GrandchildProtein", slug="grandchild") - grandchild_lineage = Lineage.objects.create(protein=grandchild_protein, parent=child_lineage) + grandchild_lineage = Lineage.objects.create( + protein=grandchild_protein, parent=child_lineage + ) # Verify multi-level tree assert grandchild_lineage.get_root() == root_lineage diff --git a/backend/tests/test_proteins/test_reversion.py b/backend/tests/test_proteins/test_reversion.py index affc8f68..3cf3b8a4 100644 --- a/backend/tests/test_proteins/test_reversion.py +++ b/backend/tests/test_proteins/test_reversion.py @@ -66,7 +66,9 @@ class TestReversionCompareAdmin(TestCase): def setUp(self): """Create a superuser for admin access.""" - self.user = User.objects.create_superuser(username="admin", email="admin@test.com", password="password") + self.user = User.objects.create_superuser( + username="admin", email="admin@test.com", password="password" + ) self.client.login(username="admin", password="password") def test_admin_history_view_accessible(self): diff --git a/backend/tests/test_proteins/test_tasks.py b/backend/tests/test_proteins/test_tasks.py index eae3dc8d..8d0adfaa 100644 --- a/backend/tests/test_proteins/test_tasks.py +++ b/backend/tests/test_proteins/test_tasks.py @@ -4,7 +4,12 @@ import pytest -from proteins.factories import DyeStateFactory, MicroscopeFactory, OpticalConfigWithFiltersFactory, StateFactory +from proteins.factories import ( + DyeStateFactory, + MicroscopeFactory, + OpticalConfigWithFiltersFactory, + StateFactory, +) from proteins.models import OcFluorEff from proteins.tasks import calc_fret, calculate_scope_report diff --git a/backend/tests/test_proteins/test_views.py b/backend/tests/test_proteins/test_views.py index 22e92597..2138ff02 100644 --- a/backend/tests/test_proteins/test_views.py +++ b/backend/tests/test_proteins/test_views.py @@ -8,7 +8,12 @@ from django.test import TestCase from django.urls import reverse -from proteins.factories import DyeStateFactory, MicroscopeFactory, OpticalConfigWithFiltersFactory, StateFactory +from proteins.factories import ( + DyeStateFactory, + MicroscopeFactory, + OpticalConfigWithFiltersFactory, + StateFactory, +) from proteins.models import OcFluorEff, Protein, Spectrum, State from proteins.tasks import calculate_scope_report @@ -135,7 +140,10 @@ def test_spectrum_preview_file_upload_success(self): self.client.login(username="testuser", password="testpass") # Create a mock CSV file with consecutive wavelengths - file_content = b"400,0.1\n401,0.2\n402,0.3\n403,0.5\n404,0.8\n405,1.0\n406,0.8\n407,0.5\n408,0.3\n409,0.1" + file_content = ( + b"400,0.1\n401,0.2\n402,0.3\n403,0.5\n404,0.8\n" + b"405,1.0\n406,0.8\n407,0.5\n408,0.3\n409,0.1" + ) uploaded_file = SimpleUploadedFile("spectrum.csv", file_content, content_type="text/csv") # Use multipart form data for file upload @@ -398,7 +406,9 @@ def test_scope_report_json_with_both_states_and_dyes(self, client): OcFluorEff.objects.bulk_create( [ OcFluorEff(oc=oc, fluor=state, fluor_name=str(state), ex_eff=0.8, em_eff=0.7), - OcFluorEff(oc=oc, fluor=dye_state, fluor_name=str(dye_state), ex_eff=0.9, em_eff=0.6), + OcFluorEff( + oc=oc, fluor=dye_state, fluor_name=str(dye_state), ex_eff=0.9, em_eff=0.6 + ), ] ) diff --git a/backend/tests/test_users/test_avatar.py b/backend/tests/test_users/test_avatar.py index 5b323b86..66321a60 100644 --- a/backend/tests/test_users/test_avatar.py +++ b/backend/tests/test_users/test_avatar.py @@ -117,7 +117,9 @@ def setUp(self): super().setUp() # Create two avatars for the user self.avatar1 = Avatar.objects.create( - user=self.user, primary=True, avatar=SimpleUploadedFile("avatar1.png", b"image1", content_type="image/png") + user=self.user, + primary=True, + avatar=SimpleUploadedFile("avatar1.png", b"image1", content_type="image/png"), ) self.avatar2 = Avatar.objects.create( user=self.user, @@ -161,7 +163,9 @@ class TestAvatarDelete(AvatarTestCase): def setUp(self): super().setUp() self.avatar = Avatar.objects.create( - user=self.user, primary=True, avatar=SimpleUploadedFile("avatar.png", b"image", content_type="image/png") + user=self.user, + primary=True, + avatar=SimpleUploadedFile("avatar.png", b"image", content_type="image/png"), ) def test_avatar_delete_view_accessible(self): @@ -194,7 +198,9 @@ class TestAvatarTemplateTag(AvatarTestCase): def setUp(self): super().setUp() self.avatar = Avatar.objects.create( - user=self.user, primary=True, avatar=SimpleUploadedFile("avatar.png", b"image", content_type="image/png") + user=self.user, + primary=True, + avatar=SimpleUploadedFile("avatar.png", b"image", content_type="image/png"), ) def test_avatar_url_returns_url(self): @@ -254,7 +260,9 @@ def test_avatar_workflow_upload_change_delete(self): # 4. Delete avatars url = reverse("avatar:delete") - response = self.client.post(url, {"choices": [str(avatar1_id), str(avatar2.id)]}, follow=True) + response = self.client.post( + url, {"choices": [str(avatar1_id), str(avatar2.id)]}, follow=True + ) self.assertEqual(response.status_code, 200) self.assertEqual(Avatar.objects.filter(user=self.user).count(), 0) @@ -264,12 +272,16 @@ def test_multiple_users_can_have_avatars(self): # Create avatar for user1 avatar1 = Avatar.objects.create( - user=self.user, primary=True, avatar=SimpleUploadedFile("u1.png", b"img1", content_type="image/png") + user=self.user, + primary=True, + avatar=SimpleUploadedFile("u1.png", b"img1", content_type="image/png"), ) # Create avatar for user2 avatar2 = Avatar.objects.create( - user=user2, primary=True, avatar=SimpleUploadedFile("u2.png", b"img2", content_type="image/png") + user=user2, + primary=True, + avatar=SimpleUploadedFile("u2.png", b"img2", content_type="image/png"), ) self.assertEqual(Avatar.objects.filter(user=self.user).count(), 1) diff --git a/backend/tests/test_users/test_urls.py b/backend/tests/test_users/test_urls.py index 722bf7b7..0104b101 100644 --- a/backend/tests/test_users/test_urls.py +++ b/backend/tests/test_users/test_urls.py @@ -29,7 +29,9 @@ def test_redirect_resolve(self): def test_detail_reverse(self): """users:detail should reverse to /users/testuser/.""" - self.assertEqual(reverse("users:detail", kwargs={"username": f"{self.uname}"}), f"/users/{self.uname}/") + self.assertEqual( + reverse("users:detail", kwargs={"username": f"{self.uname}"}), f"/users/{self.uname}/" + ) def test_detail_resolve(self): """/users/testuser/ should resolve to users:detail.""" diff --git a/backend/tests_e2e/conftest.py b/backend/tests_e2e/conftest.py index e10bca2b..da3f6d60 100644 --- a/backend/tests_e2e/conftest.py +++ b/backend/tests_e2e/conftest.py @@ -52,7 +52,15 @@ from collections.abc import Iterator from django.contrib.auth.models import AbstractUser - from playwright.sync_api import Browser, BrowserContext, ConsoleMessage, Page, Request, Response, ViewportSize + from playwright.sync_api import ( + Browser, + BrowserContext, + ConsoleMessage, + Page, + Request, + Response, + ViewportSize, + ) from pytest import FixtureRequest from sourcemap.decoder import SourceMapIndex @@ -118,7 +126,10 @@ def _color_text(text: str, color_code: int) -> str: env={**os.environ, "TEST_BUILD": "1"}, ) if result.returncode != 0: - print(_color_text(f"❌ Build failed with exit code {result.returncode}", RED), flush=True) + print( + _color_text(f"❌ Build failed with exit code {result.returncode}", RED), + flush=True, + ) print(f"STDOUT: {result.stdout}", flush=True) print(f"STDERR: {result.stderr}", flush=True) raise RuntimeError(f"Frontend build failed: {result.stderr}") @@ -154,7 +165,13 @@ def _frontend_assets_need_rebuild(manifest_file) -> bool: sources = list(frontend_src.rglob("*")) packages = Path(__file__).parent.parent.parent / "packages" sources += list(packages.rglob("src/**/*")) - return bool(any(f.stat().st_mtime > manifest_mtime for f in sources if f.is_file() and not f.name.startswith("."))) + return bool( + any( + f.stat().st_mtime > manifest_mtime + for f in sources + if f.is_file() and not f.name.startswith(".") + ) + ) # Frontend assets are built in pytest_configure hook (runs before Django import) @@ -195,7 +212,9 @@ def replace_location(match: re.Match) -> str: if bundle_name not in _SOURCE_MAP_CACHE and bundle_path.exists(): try: content = bundle_path.read_text() - sm_match = re.search(r"//# sourceMappingURL=data:[^,]+,(.+)$", content, re.MULTILINE) + sm_match = re.search( + r"//# sourceMappingURL=data:[^,]+,(.+)$", content, re.MULTILINE + ) if sm_match: sm_json = base64.b64decode(sm_match.group(1)).decode() _SOURCE_MAP_CACHE[bundle_name] = load_sourcemap(sm_json) @@ -263,7 +282,9 @@ def __init__( self.page_errors: list[Exception] = [] self.request_failures: list[str] = [] self.http_errors: list[str] = [] - self._compiled_patterns = [re.compile(pattern, re.IGNORECASE) for pattern in ignore_patterns] + self._compiled_patterns = [ + re.compile(pattern, re.IGNORECASE) for pattern in ignore_patterns + ] # Attach all event listeners page.on("console", self.on_console) @@ -287,7 +308,9 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: def _should_ignore(self, *texts: str) -> bool: """Check if any text matches any ignore pattern (case-insensitive).""" - return any(pattern.search(text) for pattern in self._compiled_patterns for text in texts if text) + return any( + pattern.search(text) for pattern in self._compiled_patterns for text in texts if text + ) def on_console(self, msg: ConsoleMessage) -> None: """Collect console messages unless they match ignore patterns.""" @@ -295,7 +318,11 @@ def on_console(self, msg: ConsoleMessage) -> None: if msg.type in {"debug", "log"}: print(f"[Console {msg.type}] {msg.text} (at {url})") return - text = _apply_source_maps_to_stack(msg.text) if msg.type == "error" and "at " in msg.text else msg.text + text = ( + _apply_source_maps_to_stack(msg.text) + if msg.type == "error" and "at " in msg.text + else msg.text + ) if not self._should_ignore(text, url): # Store message with source-mapped text @@ -309,7 +336,9 @@ def on_page_error(self, error: Exception) -> None: # Apply source maps to stack traces in page errors if hasattr(error, "stack") and error.stack: # Create a wrapper since error.stack is read-only - mapped_error = SimpleNamespace(stack=_apply_source_maps_to_stack(error.stack), message=str(error)) + mapped_error = SimpleNamespace( + stack=_apply_source_maps_to_stack(error.stack), message=str(error) + ) self.page_errors.append(mapped_error) else: self.page_errors.append(error) @@ -335,7 +364,9 @@ def assert_no_errors(self) -> None: """Fail the test if any errors were collected.""" # Critical: uncaught page errors fail immediately if self.page_errors: - error_details = "\n".join(f" - {textwrap.indent(err.stack, ' ')}" for err in self.page_errors) + error_details = "\n".join( + f" - {textwrap.indent(err.stack, ' ')}" for err in self.page_errors + ) pytest.fail(f"Uncaught page errors detected:\n{error_details}", pytrace=False) # Collect other error types @@ -362,7 +393,9 @@ def assert_no_console_errors(page: Page, request: FixtureRequest) -> Iterator[No def auth_user() -> AbstractUser: """Create authenticated user with verified email.""" User = get_user_model() - user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + user = User.objects.create_user( + username="testuser", email="test@example.com", password="testpass123" + ) EmailAddress.objects.create(user=user, email=user.email, verified=True, primary=True) return user diff --git a/backend/tests_e2e/snapshot_plugin.py b/backend/tests_e2e/snapshot_plugin.py index e41472ea..c548f3d5 100644 --- a/backend/tests_e2e/snapshot_plugin.py +++ b/backend/tests_e2e/snapshot_plugin.py @@ -44,13 +44,18 @@ def pytest_addoption(parser: pytest.Parser) -> None: "--min-percent-diff", type=float, default=0, - help="Minimum percent pixels allowed to be different before failing a visual snapshot test (default: 0)", + help=( + "Minimum percent pixels allowed to be different before failing " + "a visual snapshot test (default: 0)" + ), ) def _visual_snapshots_enabled(config: pytest.Config) -> bool: """Check if visual snapshots are enabled via CLI flag or environment variable.""" - return config.getoption("--visual-snapshots", False) or os.environ.get("VISUAL_SNAPSHOTS", "").lower() in ( + return config.getoption("--visual-snapshots", False) or os.environ.get( + "VISUAL_SNAPSHOTS", "" + ).lower() in ( "1", "true", "yes", @@ -68,7 +73,8 @@ def _cleanup_snapshot_failures(pytestconfig: pytest.Config): """ Clean up snapshot failures directory once at the beginning of test session. - The snapshot storage path is relative to each test folder, modeling after the React snapshot locations + The snapshot storage path is relative to each test folder, modeling after the React + snapshot locations """ root_dir = Path(pytestconfig.rootpath) # type: ignore[attr-defined] @@ -78,7 +84,8 @@ def _cleanup_snapshot_failures(pytestconfig: pytest.Config): SnapshotPaths.failures_path = root_dir / "snapshot_failures" - # Clean up the entire failures directory at session start so past failures don't clutter the result + # Clean up the entire failures directory at session start so past failures don't + # clutter the result if SnapshotPaths.failures_path.exists(): shutil.rmtree(SnapshotPaths.failures_path, ignore_errors=True) @@ -164,7 +171,11 @@ def compare( all_mask_selectors.extend(mask_elements) # Convert selectors to locators - masks = _create_locators_from_selectors(img_or_page, all_mask_selectors) if all_mask_selectors else [] + masks = ( + _create_locators_from_selectors(img_or_page, all_mask_selectors) + if all_mask_selectors + else [] + ) img = img_or_page.screenshot( animations="disabled", @@ -180,7 +191,9 @@ def compare( test_file_name_without_extension = current_test_file_path.stem # Created a nested folder to store screenshots: snapshot/test_file_name/test_name/ - test_file_snapshot_dir = snapshots_path / test_file_name_without_extension / test_name_without_params + test_file_snapshot_dir = ( + snapshots_path / test_file_name_without_extension / test_name_without_params + ) test_file_snapshot_dir.mkdir(parents=True, exist_ok=True) screenshot_file = test_file_snapshot_dir / name @@ -194,13 +207,17 @@ def compare( if update_snapshot: screenshot_file.write_bytes(img) - failures.append(f"{SNAPSHOT_MESSAGE_PREFIX} Snapshots updated. Please review images. {screenshot_file}") + failures.append( + f"{SNAPSHOT_MESSAGE_PREFIX} Snapshots updated. Please review images. " + f"{screenshot_file}" + ) return if not screenshot_file.exists(): screenshot_file.write_bytes(img) failures.append( - f"{SNAPSHOT_MESSAGE_PREFIX} New snapshot(s) created. Please review images. {screenshot_file}" + f"{SNAPSHOT_MESSAGE_PREFIX} New snapshot(s) created. Please review images. " + f"{screenshot_file}" ) return diff --git a/backend/tests_e2e/test_e2e.py b/backend/tests_e2e/test_e2e.py index cfd7801c..28d3da8c 100644 --- a/backend/tests_e2e/test_e2e.py +++ b/backend/tests_e2e/test_e2e.py @@ -37,7 +37,9 @@ SEQ = "MVSKGEELFTGVVPILVELDGDVNGHKFSVSGEGEGDATYGKLTLKFICTTGKLPVPWPTLVTTLTYGVQCFS" # Reverse translation of DGDVNGHKFSVSGEGEGDATYGKLTLKFICT -CDNA = "gatggcgatgtgaacggccataaatttagcgtgagcggcgaaggcgaaggcgatgcgacctatggcaaactgaccctgaaatttatttgcacc" +CDNA = ( + "gatggcgatgtgaacggccataaatttagcgtgagcggcgaaggcgaaggcgatgcgacctatggcaaactgaccctgaaatttatttgcacc" +) def _select2_enter(selector: str, text: str, page: Page) -> None: @@ -54,7 +56,9 @@ def _select2_enter(selector: str, text: str, page: Page) -> None: page.keyboard.press("Enter") -def test_main_page_loads_with_assets(live_server: LiveServer, page: Page, assert_snapshot: Callable) -> None: +def test_main_page_loads_with_assets( + live_server: LiveServer, page: Page, assert_snapshot: Callable +) -> None: """Test that the main page loads without errors and CSS/JS assets are applied. Verifies: @@ -300,11 +304,15 @@ def test_microscope_create( auth_page.locator('input[type="submit"]').click() auth_page.wait_for_load_state("networkidle") scope = Microscope.objects.last() - expect(auth_page).to_have_url(f"{live_server.url}{reverse('proteins:microscope-detail', args=(scope.id,))}") + expect(auth_page).to_have_url( + f"{live_server.url}{reverse('proteins:microscope-detail', args=(scope.id,))}" + ) assert_snapshot(auth_page) -def test_microscope_page_with_interaction(live_server: LiveServer, page: Page, assert_snapshot: Callable) -> None: +def test_microscope_page_with_interaction( + live_server: LiveServer, page: Page, assert_snapshot: Callable +) -> None: """Test microscope page with fluorophore selection and config switching.""" protein = ProteinFactory.create() microscope = MicroscopeFactory(name="TestScope", id="TESTSCOPE123") @@ -369,7 +377,9 @@ def test_fret_page_loads(live_server: LiveServer, page: Page, assert_snapshot: C assert_snapshot(page, mask_elements=[".highcharts-legend"]) -def test_collections_page_loads(live_server: LiveServer, page: Page, assert_snapshot: Callable) -> None: +def test_collections_page_loads( + live_server: LiveServer, page: Page, assert_snapshot: Callable +) -> None: """Test collections page loads without errors.""" url = f"{live_server.url}{reverse('proteins:collections')}" page.goto(url) @@ -400,7 +410,9 @@ def test_problems_gaps_page_loads(live_server: LiveServer, page: Page) -> None: expect(page).to_have_url(url) -def test_protein_table_page_loads(live_server: LiveServer, page: Page, assert_snapshot: Callable) -> None: +def test_protein_table_page_loads( + live_server: LiveServer, page: Page, assert_snapshot: Callable +) -> None: """Test protein table page loads without errors.""" # Create minimal data - table functionality doesn't require 10 proteins ProteinFactory.create_batch(3) @@ -409,7 +421,9 @@ def test_protein_table_page_loads(live_server: LiveServer, page: Page, assert_sn expect(page).to_have_url(url) -def test_interactive_chart_page(live_server: LiveServer, page: Page, assert_snapshot: Callable) -> None: +def test_interactive_chart_page( + live_server: LiveServer, page: Page, assert_snapshot: Callable +) -> None: """Test interactive chart page with axis selection.""" # Create minimal data - chart interaction doesn't require 6 proteins ProteinFactory.create( @@ -458,12 +472,17 @@ def test_interactive_chart_page(live_server: LiveServer, page: Page, assert_snap # Click X-axis radio button for quantum yield (Bootstrap 5 uses label with 'for' attribute) page.locator("label[for='Xqy']").click() - # Click Y-axis radio button for extinction coefficient (Bootstrap 5 uses label with 'for' attribute) + # Click Y-axis radio button for extinction coefficient + # (Bootstrap 5 uses label with 'for' attribute) page.locator("label[for='Yext_coeff']").click() -@pytest.mark.parametrize("viewname", ["microscope-embed", "microscope-detail", "microscope-report"]) -def test_microscope_views(live_server: LiveServer, page: Page, viewname: str, assert_snapshot: Callable) -> None: +@pytest.mark.parametrize( + "viewname", ["microscope-embed", "microscope-detail", "microscope-report"] +) +def test_microscope_views( + live_server: LiveServer, page: Page, viewname: str, assert_snapshot: Callable +) -> None: """Test embedded microscope viewer with chart rendering.""" microscope = MicroscopeFactory(name="TestScope", id="TESTSCOPE123") OpticalConfigWithFiltersFactory.create_batch(2, microscope=microscope) @@ -485,7 +504,9 @@ def test_microscope_views(live_server: LiveServer, page: Page, viewname: str, as # assert_snapshot(page) -def test_protein_comparison(live_server: LiveServer, page: Page, assert_snapshot: Callable) -> None: +def test_protein_comparison( + live_server: LiveServer, page: Page, assert_snapshot: Callable +) -> None: """Test protein comparison page shows mutations between two proteins.""" protein1 = ProteinFactory.create(name="GFP1", seq=SEQ) protein2 = ProteinFactory.create(name="GFP2", seq=SEQ.replace("ELDG", "ETTG")) @@ -504,7 +525,9 @@ def test_protein_comparison(live_server: LiveServer, page: Page, assert_snapshot assert_snapshot(page.get_by_text("Sequence Comparison").locator("css=+ div").screenshot()) -def test_advanced_search(live_server: LiveServer, page: Page, assert_snapshot: Callable, browser: Browser) -> None: +def test_advanced_search( + live_server: LiveServer, page: Page, assert_snapshot: Callable, browser: Browser +) -> None: """Test advanced search with multiple filters.""" if browser.browser_type.name != "chromium": pytest.skip("Skipping microscope create test on non-chromium browser due to flakiness.") @@ -662,7 +685,9 @@ def test_blast_search(live_server: LiveServer, page: Page, assert_snapshot: Call assert_snapshot(page) -def test_protein_detail_egfp(page: Page, live_server: LiveServer, assert_snapshot: Callable) -> None: +def test_protein_detail_egfp( + page: Page, live_server: LiveServer, assert_snapshot: Callable +) -> None: egfp = create_egfp() url = f"{live_server.url}{reverse('proteins:protein-detail', args=(egfp.slug,))}" page.goto(url) @@ -771,7 +796,9 @@ def test_favorite_button_interaction( "proteins:microscopes", ], ) -def test_page_simply_loads_without_errors(live_server: LiveServer, page: Page, viewname: str) -> None: +def test_page_simply_loads_without_errors( + live_server: LiveServer, page: Page, viewname: str +) -> None: """Test that a simple page loads without errors.""" url = f"{live_server.url}{reverse(viewname)}" page.goto(url) diff --git a/backend/tests_e2e/test_spectra_viewer.py b/backend/tests_e2e/test_spectra_viewer.py index 0069a8ef..aa262c76 100644 --- a/backend/tests_e2e/test_spectra_viewer.py +++ b/backend/tests_e2e/test_spectra_viewer.py @@ -61,7 +61,9 @@ def test_spectra_viewer_add_from_input(spectra_viewer: Page, assert_snapshot: Ca @pytest.mark.parametrize("method", ["spacebar", "click"]) -def test_spectra_viewer_add_from_spacebar(spectra_viewer: Page, assert_snapshot: Callable, method: str) -> None: +def test_spectra_viewer_add_from_spacebar( + spectra_viewer: Page, assert_snapshot: Callable, method: str +) -> None: """Test the spectra viewer page loads without console errors.""" tab_wrapper = spectra_viewer.locator(".tab-wrapper") expect(tab_wrapper.get_by_text("Type to search...")).to_be_visible() @@ -209,7 +211,9 @@ def test_qy_ec_scaling_invertibility(spectra_viewer: Page) -> None: spectra_viewer.keyboard.press("Enter") # Wait for EGFP to be added - expect(spectra_viewer.locator(".tab-wrapper").get_by_text(re.compile(r"^EGFP"))).to_be_visible() + expect( + spectra_viewer.locator(".tab-wrapper").get_by_text(re.compile(r"^EGFP")) + ).to_be_visible() def _test_scaling_toggle_indempotent(page: Page, subtype: str, key: str) -> None: _ser = page.locator(f"g.highcharts-series.subtype-{subtype}") @@ -368,9 +372,15 @@ def test_spectra_graph(live_server: LiveServer, page: Page, params: dict) -> Non # Verify that x-axis extremes match the parameters x_axis_labels = spectra_viewer.locator(".highcharts-xaxis-labels text") label_texts = x_axis_labels.all_text_contents() - label_values = [int(text) for text in label_texts if text.strip() and text.strip().isdigit()] - assert min(label_values) >= params["xMin"], f"Min label {min(label_values)} should be >= {params['xMin']}" - assert max(label_values) <= params["xMax"], f"Max label {max(label_values)} should be <= {params['xMax']}" + label_values = [ + int(text) for text in label_texts if text.strip() and text.strip().isdigit() + ] + assert min(label_values) >= params["xMin"], ( + f"Min label {min(label_values)} should be >= {params['xMin']}" + ) + assert max(label_values) <= params["xMax"], ( + f"Max label {max(label_values)} should be <= {params['xMax']}" + ) def test_subtype_visibility_toggles(spectra_viewer: Page) -> None: @@ -486,7 +496,9 @@ def test_x_range_pickers(spectra_viewer: Page) -> None: # Verify chart is still zoomed after reload label_texts_after = x_axis_labels.all_text_contents() - label_values_after = [int(text) for text in label_texts_after if text.strip() and text.strip().isdigit()] + label_values_after = [ + int(text) for text in label_texts_after if text.strip() and text.strip().isdigit() + ] min_label_after = min(label_values_after) max_label_after = max(label_values_after) assert min_label_after >= 400, f"Min label after reload {min_label_after} should be >= 400" @@ -501,7 +513,9 @@ def test_x_range_pickers(spectra_viewer: Page) -> None: # verify chart is back to full range label_texts_full = x_axis_labels.all_text_contents() - label_values_full = [int(text) for text in label_texts_full if text.strip() and text.strip().isdigit()] + label_values_full = [ + int(text) for text in label_texts_full if text.strip() and text.strip().isdigit() + ] assert min(label_values_full) == 300 assert max(label_values_full) == 900 diff --git a/pyproject.toml b/pyproject.toml index 4ffd001a..fb386935 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ omit = ["*/migrations/*", "*/tests/*", "proteins/management/**"] # ==== ruff ==== [tool.ruff] src = ["backend/"] -line-length = 119 +line-length = 99 target-version = "py313" exclude = ['*/migrations/*', '*/static/CACHE/*', '.venv'] fix = true diff --git a/scripts/extract_fa_icons.py b/scripts/extract_fa_icons.py index b487ebb6..009c28f6 100644 --- a/scripts/extract_fa_icons.py +++ b/scripts/extract_fa_icons.py @@ -87,7 +87,8 @@ def extract_svgs( Args: outdir: destination folder (created if missing) where the selected SVGs will be copied. - extract_dir_name: the subfolder name under the temporary directory where the zip will be extracted. + extract_dir_name: the subfolder name under the temporary directory where the + zip will be extracted. """ with tempfile.TemporaryDirectory() as tmpdir: tmpdir_path = Path(tmpdir) @@ -112,7 +113,8 @@ def extract_svgs( svg_path = next(extract_path.glob("fontawesome-*/svgs/")) except StopIteration: raise RuntimeError( - "Could not find extracted Font Awesome folder. Check the structure of the zip file." + "Could not find extracted Font Awesome folder. " + "Check the structure of the zip file." ) from None outdir = Path(outdir) @@ -133,9 +135,14 @@ def extract_svgs( if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Download Font Awesome and extract selected SVGs.") + parser = argparse.ArgumentParser( + description="Download Font Awesome and extract selected SVGs." + ) parser.add_argument( - "--outdir", "-o", default="backend/fpbase/static/icons", help="Destination folder for copied SVGs" + "--outdir", + "-o", + default="backend/fpbase/static/icons", + help="Destination folder for copied SVGs", ) parser.add_argument( "--extract-dir-name", @@ -151,4 +158,8 @@ def extract_svgs( ) args = parser.parse_args() - extract_svgs(outdir=args.outdir, extract_dir_name=args.extract_dir_name, keep_existing=args.keep_existing) + extract_svgs( + outdir=args.outdir, + extract_dir_name=args.extract_dir_name, + keep_existing=args.keep_existing, + ) From fce3d0fe4848e620d8c1b657384dabe23aac0997 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Nov 2025 12:14:39 -0500 Subject: [PATCH 4/5] furb --- backend/fpseq/mutations.py | 2 +- backend/proteins/forms/microscope.py | 6 ++---- pyproject.toml | 1 + scripts/extract_fa_icons.py | 3 +-- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/fpseq/mutations.py b/backend/fpseq/mutations.py index 8dbd5fa9..b04682a8 100644 --- a/backend/fpseq/mutations.py +++ b/backend/fpseq/mutations.py @@ -57,7 +57,7 @@ (?P[{0}*]+)? (?:ext(?P[{0}]+))? (?:,|$|/|\s)""".format(r"A-Z*", "{1}"), - re.X, + re.VERBOSE, ) diff --git a/backend/proteins/forms/microscope.py b/backend/proteins/forms/microscope.py index 62da68da..55ed135e 100644 --- a/backend/proteins/forms/microscope.py +++ b/backend/proteins/forms/microscope.py @@ -265,10 +265,8 @@ def lookup(fname, n=None): [n.strip() for n in brackets.sub("", item).split(",") if n.strip()] ) else: - if item.endswith(","): - item = item[:-1] - if item.startswith(","): - item = item[1:] + item = item.removesuffix(",") + item = item.removeprefix(",") splt.extend([n.strip() for n in item.split(",")]) else: splt = [i.strip() for i in line.split(",")] diff --git a/pyproject.toml b/pyproject.toml index fb386935..d3ccc1c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,7 @@ select = [ "G", # flake8-logging-format "TC", # flake8-type-checking "SIM", # Common simplification rules + "FURB", ] ignore = [ "B905", # `zip()` without an explicit `strict=` parameter diff --git a/scripts/extract_fa_icons.py b/scripts/extract_fa_icons.py index 009c28f6..5248ed70 100644 --- a/scripts/extract_fa_icons.py +++ b/scripts/extract_fa_icons.py @@ -100,8 +100,7 @@ def extract_svgs( with requests.get(URL, stream=True) as r: r.raise_for_status() with open(zip_path, "wb") as f: - for chunk in r.iter_content(8192): - f.write(chunk) + f.writelines(r.iter_content(8192)) print("Extracting...") with zipfile.ZipFile(zip_path) as z: From c8a97a3f474940e80c777d4392758da92c4fd520 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:38:46 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/proteins/models/fluorophore.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/proteins/models/fluorophore.py b/backend/proteins/models/fluorophore.py index 90b407fc..fe99b03f 100644 --- a/backend/proteins/models/fluorophore.py +++ b/backend/proteins/models/fluorophore.py @@ -241,7 +241,11 @@ def local_brightness(self) -> float: """Brightness relative to spectral neighbors. 1 = average.""" if not (self.em_max and self.brightness): return 1 - avg = FluorState.objects.exclude(id=self.id).filter(em_max__around=self.em_max).aggregate(Avg("brightness")) + avg = ( + FluorState.objects.exclude(id=self.id) + .filter(em_max__around=self.em_max) + .aggregate(Avg("brightness")) + ) try: return round(self.brightness / avg["brightness__avg"], 4) except TypeError: