Merge branch 'develop' into 17292-detect-infinite-loop-in-cable-trace

This commit is contained in:
Brian Tiemann 2024-12-16 08:57:39 -05:00
commit 0d75210649
59 changed files with 86310 additions and 75440 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.7 placeholder: v4.1.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -39,7 +39,7 @@ body:
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.7 placeholder: v4.1.8
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -49,6 +49,10 @@ This key lists all models which have been registered in NetBox which are not des
This store maintains all registered items for plugins, such as navigation menus, template extensions, etc. This store maintains all registered items for plugins, such as navigation menus, template extensions, etc.
### `request_processors`
A list of context managers to invoke when processing a request e.g. in middleware or when executing a background job. Request processors can be registered with the `@register_request_processor` decorator.
### `search` ### `search`
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it. A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.

View File

@ -1,6 +1,32 @@
# NetBox v4.1 # NetBox v4.1
## v4.1.7 (FUTURE) ## v4.1.8 (2024-12-12)
### Enhancements
* [#17071](https://github.com/netbox-community/netbox/issues/17071) - Enable OOB IP address designation during bulk import
* [#17465](https://github.com/netbox-community/netbox/issues/17465) - Enable designation of rack type during bulk import & bulk edit
* [#17889](https://github.com/netbox-community/netbox/issues/17889) - Enable designating an IP address as out-of-band for a device upon creation
* [#17960](https://github.com/netbox-community/netbox/issues/17960) - Add L2TP, PPTP, Wireguard, and OpenVPN tunnel types
* [#18021](https://github.com/netbox-community/netbox/issues/18021) - Automatically clear cache on restart when `DEBUG` is enabled
* [#18061](https://github.com/netbox-community/netbox/issues/18061) - Omit stack trace from rendered device/VM configuration when an exception is raised
* [#18065](https://github.com/netbox-community/netbox/issues/18065) - Include status in device details when hovering on rack elevation
* [#18211](https://github.com/netbox-community/netbox/issues/18211) - Enable the dynamic registration of context managers for request processing
### Bug Fixes
* [#14044](https://github.com/netbox-community/netbox/issues/14044) - Fix unhandled AttributeError exception when bulk renaming objects
* [#17490](https://github.com/netbox-community/netbox/issues/17490) - Fix dynamic inclusion support for config templates
* [#17810](https://github.com/netbox-community/netbox/issues/17810) - Fix validation of racked device fields when modifying via REST API
* [#17820](https://github.com/netbox-community/netbox/issues/17820) - Ensure default custom field values are populated when creating new modules
* [#18044](https://github.com/netbox-community/netbox/issues/18044) - Show plugin-generated alerts within UI views for custom scripts
* [#18150](https://github.com/netbox-community/netbox/issues/18150) - Fix REST API pagination for low `MAX_PAGE_SIZE` values
* [#18183](https://github.com/netbox-community/netbox/issues/18183) - Omit UI navigation bar when printing
* [#18213](https://github.com/netbox-community/netbox/issues/18213) - Fix searching for ASN ranges by name
---
## v4.1.7 (2024-11-21)
### Enhancements ### Enhancements

View File

@ -1,4 +1,6 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from django.core.cache import cache
from django.db import models from django.db import models
from django.db.migrations.operations import AlterModelOptions from django.db.migrations.operations import AlterModelOptions
@ -22,3 +24,7 @@ class CoreConfig(AppConfig):
# Register models # Register models
register_models(*self.get_models()) register_models(*self.get_models())
# Clear Redis cache on startup in development mode
if settings.DEBUG:
cache.clear()

View File

@ -359,6 +359,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
required=False required=False
) )
rack_type = DynamicModelChoiceField(
label=_('Rack type'),
queryset=RackType.objects.all(),
required=False,
)
serial = forms.CharField( serial = forms.CharField(
max_length=50, max_length=50,
required=False, required=False,
@ -438,7 +443,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
model = Rack model = Rack
fieldsets = ( fieldsets = (
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')), FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
FieldSet( FieldSet(
'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit', 'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit',

View File

@ -256,6 +256,13 @@ class RackImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Name of assigned role') help_text=_('Name of assigned role')
) )
rack_type = CSVModelChoiceField(
label=_('Rack type'),
queryset=RackType.objects.all(),
to_field_name='model',
required=False,
help_text=_('Rack type model')
)
form_factor = CSVChoiceField( form_factor = CSVChoiceField(
label=_('Type'), label=_('Type'),
choices=RackFormFactorChoices, choices=RackFormFactorChoices,
@ -265,8 +272,13 @@ class RackImportForm(NetBoxModelImportForm):
width = forms.ChoiceField( width = forms.ChoiceField(
label=_('Width'), label=_('Width'),
choices=RackWidthChoices, choices=RackWidthChoices,
required=False,
help_text=_('Rail-to-rail width (in inches)') help_text=_('Rail-to-rail width (in inches)')
) )
u_height = forms.IntegerField(
required=False,
label=_('Height (U)')
)
outer_unit = CSVChoiceField( outer_unit = CSVChoiceField(
label=_('Outer unit'), label=_('Outer unit'),
choices=RackDimensionUnitChoices, choices=RackDimensionUnitChoices,
@ -289,9 +301,9 @@ class RackImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Rack model = Rack
fields = ( fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag', 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
) )
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
@ -303,6 +315,16 @@ class RackImportForm(NetBoxModelImportForm):
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
def clean(self):
super().clean()
# width & u_height must be set if not specifying a rack type on import
if not self.instance.pk:
if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('width'):
raise forms.ValidationError(_("Width must be set if not specifying a rack type."))
if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('u_height'):
raise forms.ValidationError(_("U height must be set if not specifying a rack type."))
class RackReservationImportForm(NetBoxModelImportForm): class RackReservationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(

View File

@ -1277,6 +1277,11 @@ class Module(PrimaryModel, ConfigContextModel):
if not disable_replication: if not disable_replication:
create_instances.append(template_instance) create_instances.append(template_instance)
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
for component in create_instances:
component.custom_field_data = cf_defaults
if component_model is not ModuleBay: if component_model is not ModuleBay:
component_model.objects.bulk_create(create_instances) component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object # Emit the post_save signal for each newly created object

View File

@ -1,5 +1,3 @@
import traceback
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
@ -2106,7 +2104,8 @@ class DeviceRenderConfigView(generic.ObjectView):
# If a direct export has been requested, return the rendered template content as a # If a direct export has been requested, return the rendered template content as a
# downloadable file. # downloadable file.
if request.GET.get('export'): if request.GET.get('export'):
response = HttpResponse(context['rendered_config'], content_type='text') content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt" filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response return response
@ -2124,17 +2123,18 @@ class DeviceRenderConfigView(generic.ObjectView):
# Render the config template # Render the config template
rendered_config = None rendered_config = None
error_message = None
if config_template := instance.get_config_template(): if config_template := instance.get_config_template():
try: try:
rendered_config = config_template.render(context=context_data) rendered_config = config_template.render(context=context_data)
except TemplateError as e: except TemplateError as e:
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e)) error_message = _("An error occurred while rendering the template: {error}").format(error=e)
rendered_config = traceback.format_exc()
return { return {
'config_template': config_template, 'config_template': config_template,
'context_data': context_data, 'context_data': context_data,
'rendered_config': rendered_config, 'rendered_config': rendered_config,
'error_message': error_message,
} }

View File

@ -211,8 +211,10 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
qs_filter = Q(description__icontains=value) return queryset.filter(
return queryset.filter(qs_filter) Q(name__icontains=value) |
Q(description__icontains=value)
)
class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):

View File

@ -38,12 +38,14 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def get_limit(self, request): def get_limit(self, request):
if self.limit_query_param: if self.limit_query_param:
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE:
MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
try: try:
limit = int(request.query_params[self.limit_query_param]) limit = int(request.query_params[self.limit_query_param])
if limit < 0: if limit < 0:
raise ValueError() raise ValueError()
# Enforce maximum page size, if defined # Enforce maximum page size, if defined
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if MAX_PAGE_SIZE: if MAX_PAGE_SIZE:
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE) return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
return limit return limit

View File

@ -1,9 +1,11 @@
from contextlib import contextmanager from contextlib import contextmanager
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.utils import register_request_processor
from extras.events import flush_events from extras.events import flush_events
@register_request_processor
@contextmanager @contextmanager
def event_tracking(request): def event_tracking(request):
""" """

View File

@ -1,3 +1,5 @@
from contextlib import ExitStack
import logging import logging
import uuid import uuid
@ -10,7 +12,7 @@ from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from netbox.config import clear_config, get_config from netbox.config import clear_config, get_config
from netbox.context_managers import event_tracking from netbox.registry import registry
from netbox.views import handler_500 from netbox.views import handler_500
from utilities.api import is_api_request from utilities.api import is_api_request
from utilities.error_handlers import handle_rest_api_exception from utilities.error_handlers import handle_rest_api_exception
@ -32,8 +34,10 @@ class CoreMiddleware:
# Assign a random unique ID to the request. This will be used for change logging. # Assign a random unique ID to the request. This will be used for change logging.
request.id = uuid.uuid4() request.id = uuid.uuid4()
# Enable the event_tracking context manager and process the request. # Apply all registered request processors
with event_tracking(request): with ExitStack() as stack:
for request_processor in registry['request_processors']:
stack.enter_context(request_processor(request))
response = self.get_response(request) response = self.get_response(request)
# Check if language cookie should be renewed # Check if language cookie should be renewed

View File

@ -29,6 +29,7 @@ registry = Registry({
'model_features': dict(), 'model_features': dict(),
'models': collections.defaultdict(set), 'models': collections.defaultdict(set),
'plugins': dict(), 'plugins': dict(),
'request_processors': list(),
'search': dict(), 'search': dict(),
'tables': collections.defaultdict(dict), 'tables': collections.defaultdict(dict),
'views': collections.defaultdict(dict), 'views': collections.defaultdict(dict),

View File

@ -3,6 +3,7 @@ from netbox.registry import registry
__all__ = ( __all__ = (
'get_data_backend_choices', 'get_data_backend_choices',
'register_data_backend', 'register_data_backend',
'register_request_processor',
) )
@ -24,3 +25,12 @@ def register_data_backend():
return cls return cls
return _wrapper return _wrapper
def register_request_processor(func):
"""
Decorator for registering a request processor.
"""
registry['request_processors'].append(func)
return func

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -6,8 +6,8 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@graphiql/plugin-explorer": "3.2.2", "@graphiql/plugin-explorer": "3.2.3",
"graphiql": "3.7.1", "graphiql": "3.7.2",
"graphql": "16.9.0", "graphql": "16.9.0",
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"react": "18.3.1", "react": "18.3.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "netbox", "name": "netbox",
"version": "4.0.0", "version": "4.1.0",
"main": "dist/netbox.js", "main": "dist/netbox.js",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
@ -27,11 +27,11 @@
"bootstrap": "5.3.3", "bootstrap": "5.3.3",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"flatpickr": "4.6.13", "flatpickr": "4.6.13",
"gridstack": "10.3.1", "gridstack": "11.1.2",
"htmx.org": "1.9.12", "htmx.org": "1.9.12",
"query-string": "9.1.1", "query-string": "9.1.1",
"sass": "1.80.5", "sass": "1.82.0",
"tom-select": "2.3.1", "tom-select": "2.4.1",
"typeface-inter": "3.18.1", "typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13" "typeface-roboto-mono": "1.1.13"
}, },

View File

@ -1,18 +1,17 @@
import { RecursivePartial, TomInput, TomOption, TomSettings } from 'tom-select/dist/types/types'; import { RecursivePartial, TomOption, TomSettings } from 'tom-select/dist/types/types';
import { addClasses } from 'tom-select/src/vanilla' import { TomInput } from 'tom-select/dist/cjs/types/core';
import { addClasses } from 'tom-select/src/vanilla.ts';
import queryString from 'query-string'; import queryString from 'query-string';
import TomSelect from 'tom-select'; import TomSelect from 'tom-select';
import type { Stringifiable } from 'query-string'; import type { Stringifiable } from 'query-string';
import { DynamicParamsMap } from './dynamicParamsMap'; import { DynamicParamsMap } from './dynamicParamsMap';
// Transitional // Transitional
import { QueryFilter, PathFilter } from '../types' import { QueryFilter, PathFilter } from '../types';
import { getElement, replaceAll } from '../../util'; import { getElement, replaceAll } from '../../util';
// Extends TomSelect to provide enhanced fetching of options via the REST API // Extends TomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends TomSelect { export class DynamicTomSelect extends TomSelect {
public readonly nullOption: Nullable<TomOption> = null; public readonly nullOption: Nullable<TomOption> = null;
// Transitional code from APISelect // Transitional code from APISelect
@ -25,7 +24,7 @@ export class DynamicTomSelect extends TomSelect {
* Overrides * Overrides
*/ */
constructor( input_arg: string|TomInput, user_settings: RecursivePartial<TomSettings> ) { constructor(input_arg: string | TomInput, user_settings: RecursivePartial<TomSettings>) {
super(input_arg, user_settings); super(input_arg, user_settings);
// Glean the REST API endpoint URL from the <select> element // Glean the REST API endpoint URL from the <select> element
@ -34,7 +33,8 @@ export class DynamicTomSelect extends TomSelect {
// Override any field names set as widget attributes // Override any field names set as widget attributes
this.valueField = this.input.getAttribute('ts-value-field') || this.settings.valueField; this.valueField = this.input.getAttribute('ts-value-field') || this.settings.valueField;
this.labelField = this.input.getAttribute('ts-label-field') || this.settings.labelField; this.labelField = this.input.getAttribute('ts-label-field') || this.settings.labelField;
this.disabledField = this.input.getAttribute('ts-disabled-field') || this.settings.disabledField; this.disabledField =
this.input.getAttribute('ts-disabled-field') || this.settings.disabledField;
this.descriptionField = this.input.getAttribute('ts-description-field') || 'description'; this.descriptionField = this.input.getAttribute('ts-description-field') || 'description';
this.depthField = this.input.getAttribute('ts-depth-field') || '_depth'; this.depthField = this.input.getAttribute('ts-depth-field') || '_depth';
this.parentField = this.input.getAttribute('ts-parent-field') || null; this.parentField = this.input.getAttribute('ts-parent-field') || null;
@ -43,9 +43,9 @@ export class DynamicTomSelect extends TomSelect {
// Set the null option (if any) // Set the null option (if any)
const nullOption = this.input.getAttribute('data-null-option'); const nullOption = this.input.getAttribute('data-null-option');
if (nullOption) { if (nullOption) {
let valueField = this.settings.valueField; const valueField = this.settings.valueField;
let labelField = this.settings.labelField; const labelField = this.settings.labelField;
this.nullOption = {} this.nullOption = {};
this.nullOption[valueField] = 'null'; this.nullOption[valueField] = 'null';
this.nullOption[labelField] = nullOption; this.nullOption[labelField] = nullOption;
} }
@ -98,8 +98,8 @@ export class DynamicTomSelect extends TomSelect {
.then(response => response.json()) .then(response => response.json())
.then(apiData => { .then(apiData => {
const results: Dict[] = apiData.results; const results: Dict[] = apiData.results;
let options: Dict[] = [] const options: Dict[] = [];
for (let result of results) { for (const result of results) {
const option = self.getOptionFromData(result); const option = self.getOptionFromData(result);
options.push(option); options.push(option);
} }
@ -108,10 +108,10 @@ export class DynamicTomSelect extends TomSelect {
// Pass the options to the callback function // Pass the options to the callback function
.then(options => { .then(options => {
self.loadCallback(options, []); self.loadCallback(options, []);
}).catch(()=>{ })
.catch(() => {
self.loadCallback([], []); self.loadCallback([], []);
}); });
} }
/** /**
@ -155,14 +155,14 @@ export class DynamicTomSelect extends TomSelect {
// Compile TomOption data from an API result // Compile TomOption data from an API result
getOptionFromData(data: Dict) { getOptionFromData(data: Dict) {
let option: Dict = { const option: Dict = {
id: data[this.valueField], id: data[this.valueField],
display: data[this.labelField], display: data[this.labelField],
depth: data[this.depthField] || null, depth: data[this.depthField] || null,
description: data[this.descriptionField] || null, description: data[this.descriptionField] || null,
}; };
if (data[this.parentField]) { if (data[this.parentField]) {
let parent: Dict = data[this.parentField] as Dict; const parent: Dict = data[this.parentField] as Dict;
option['parent'] = parent[this.labelField]; option['parent'] = parent[this.labelField];
} }
if (data[this.countField]) { if (data[this.countField]) {
@ -171,7 +171,7 @@ export class DynamicTomSelect extends TomSelect {
if (data[this.disabledField]) { if (data[this.disabledField]) {
option['disabled'] = data[this.disabledField]; option['disabled'] = data[this.disabledField];
} }
return option return option;
} }
/** /**
@ -218,7 +218,6 @@ export class DynamicTomSelect extends TomSelect {
} }
} }
// Parse the `data-url` attribute to add any variables to `pathValues` as keys with empty // Parse the `data-url` attribute to add any variables to `pathValues` as keys with empty
// values. As those keys' corresponding form fields' values change, `pathValues` will be // values. As those keys' corresponding form fields' values change, `pathValues` will be
// updated to reflect the new value. // updated to reflect the new value.
@ -297,7 +296,8 @@ export class DynamicTomSelect extends TomSelect {
// value. For example, if the dependency is the `rack` field, and the `rack` field's value // value. For example, if the dependency is the `rack` field, and the `rack` field's value
// is `1`, this element's URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`. // is `1`, this element's URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
const hasReplacement = const hasReplacement =
this.api_url.includes(`{{`) && Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g'))); this.api_url.includes(`{{`) &&
Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
if (hasReplacement) { if (hasReplacement) {
if (element.value) { if (element.value) {
@ -349,5 +349,4 @@ export class DynamicTomSelect extends TomSelect {
// Load new data. // Load new data.
this.load(this.lastValue); this.load(this.lastValue);
} }
} }

View File

@ -1,10 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
// Needed for tom-select/src/vanilla.ts
"allowImportingTsExtensions": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"moduleResolution": "node", "moduleResolution": "node",
// tom-select v2.3.1 raises several TS6133 errors with noUnusedParameters
"noUnusedParameters": false,
"esModuleInterop": true, "esModuleInterop": true,
"isolatedModules": true, "isolatedModules": true,
"noUnusedLocals": true, "noUnusedLocals": true,

View File

@ -200,17 +200,17 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5"
integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw== integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==
"@graphiql/plugin-explorer@3.2.2": "@graphiql/plugin-explorer@3.2.3":
version "3.2.2" version "3.2.3"
resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-3.2.2.tgz#973d6015b6db15041902e95c3e4b746473313eb6" resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-3.2.3.tgz#03854d7e62d6e24c6552ae6706e3945b9324fa23"
integrity sha512-zeBZJUAX9h+3nXw3GLHZoxi6wwYqDBU2L/xeSXSTagJhcLNW1Hwb/t/wb296hQ1x/9nyGySsTA0DQiiWV3rCBQ== integrity sha512-yh5WXRqDPuKjVyNxUwXYjx8tImvVOx+2FGanLyjoAJP2LKQu6eDtButyJ8sExk1qW4+HCSrXxJNSPs4W7cYT3g==
dependencies: dependencies:
graphiql-explorer "^0.9.0" graphiql-explorer "^0.9.0"
"@graphiql/react@^0.26.2": "@graphiql/react@^0.27.0":
version "0.26.2" version "0.27.0"
resolved "https://registry.yarnpkg.com/@graphiql/react/-/react-0.26.2.tgz#3a1a01a569b624de8141c53eed24a7db9a523668" resolved "https://registry.yarnpkg.com/@graphiql/react/-/react-0.27.0.tgz#4475a0f4ddf25d8ebc1bfc538fb21f5f1d435916"
integrity sha512-aO4GWf/kJmqrjO+PORT/NPxwGvPGlg+mwye1v8xAlf8Q9j7P0hVtVBawYaSLUCCfJ/QnH7JAP+0VRamyooZZCw== integrity sha512-K9ZKWd+ewodbS/1kewedmITeeKLUQswMOXwIv8XFLPt3Ondodji0vr1XXXsttlyl+V2QG/9tYVV2RJ9Ch5LdrA==
dependencies: dependencies:
"@graphiql/toolkit" "^0.11.0" "@graphiql/toolkit" "^0.11.0"
"@headlessui/react" "^1.7.15" "@headlessui/react" "^1.7.15"
@ -353,17 +353,17 @@
resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e" resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e"
integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==
"@orchidjs/sifter@^1.0.3": "@orchidjs/sifter@^1.1.0":
version "1.0.3" version "1.1.0"
resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.0.3.tgz#43f42519472282eb632d0a1589184f044d64129b" resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.1.0.tgz#b36154ad0cda4898305d1ac44f318b41048a0438"
integrity sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g== integrity sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ==
dependencies: dependencies:
"@orchidjs/unicode-variants" "^1.0.4" "@orchidjs/unicode-variants" "^1.1.2"
"@orchidjs/unicode-variants@^1.0.4": "@orchidjs/unicode-variants@^1.1.2":
version "1.0.4" version "1.1.2"
resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz#6d2f812e3b19545bba2d81caffff1204de9a6a58" resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz#1fd71791a67fdd1591ebe0dcaadd3964537a824e"
integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ== integrity sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==
"@parcel/watcher-android-arm64@2.4.1": "@parcel/watcher-android-arm64@2.4.1":
version "2.4.1" version "2.4.1"
@ -1883,12 +1883,12 @@ graphiql-explorer@^0.9.0:
resolved "https://registry.yarnpkg.com/graphiql-explorer/-/graphiql-explorer-0.9.0.tgz#25f6b990bfc3e04e88c0cf419e28d12abe2c4fbe" resolved "https://registry.yarnpkg.com/graphiql-explorer/-/graphiql-explorer-0.9.0.tgz#25f6b990bfc3e04e88c0cf419e28d12abe2c4fbe"
integrity sha512-fZC/wsuatqiQDO2otchxriFO0LaWIo/ovF/CQJ1yOudmY0P7pzDiP+l9CEHUiWbizk3e99x6DQG4XG1VxA+d6A== integrity sha512-fZC/wsuatqiQDO2otchxriFO0LaWIo/ovF/CQJ1yOudmY0P7pzDiP+l9CEHUiWbizk3e99x6DQG4XG1VxA+d6A==
graphiql@3.7.1: graphiql@3.7.2:
version "3.7.1" version "3.7.2"
resolved "https://registry.yarnpkg.com/graphiql/-/graphiql-3.7.1.tgz#9fb727e15db443b22823389d13dc5d98c3ce0ff9" resolved "https://registry.yarnpkg.com/graphiql/-/graphiql-3.7.2.tgz#6a754256f4f2e6268a64e585b0fe35bf38f1b87d"
integrity sha512-kmummedOrFYs0BI5evrVY0AerOYlaMt/Sc/e+Sta1x8X6vEMYWNeUUz/kKF2NQT5BcsR3FnNdFt1Gk2QMgueGQ== integrity sha512-DL+KrX+aQdyzl+KwcqjlmdYdjyKegm7FcZJKkIQ1e56xn6Eoe8lw5F4t65gFex/45fHzv8e8CpaIcljxfJhO7A==
dependencies: dependencies:
"@graphiql/react" "^0.26.2" "@graphiql/react" "^0.27.0"
graphql-language-service@5.3.0, graphql-language-service@^5.3.0: graphql-language-service@5.3.0, graphql-language-service@^5.3.0:
version "5.3.0" version "5.3.0"
@ -1904,10 +1904,10 @@ graphql@16.9.0:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f"
integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw== integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==
gridstack@10.3.1: gridstack@11.1.2:
version "10.3.1" version "11.1.2"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.1.tgz#4ed704279c40094fc1b9e3318f20b573f2fe9f40" resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.1.2.tgz#e72091e2883f7b37cbd150c218d38eebc9fc4f18"
integrity sha512-Ra82k/88gdeiu3ZP40COS4bI4sGhNQlZAaAQ6szfPfr68zVpsXxiyLKr5zYcTpKX4jjcwyNsNNdcV1tDJc71fA== integrity sha512-6wJ5RffnFchF63/Yhs6tcZcWxRG1EgCnxgejbQsAjQ6Qj8QqKjew73jPq5c1yCAiyEAsXxI2tOJ8lZABOAZxoQ==
has-bigints@^1.0.1, has-bigints@^1.0.2: has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2" version "1.0.2"
@ -1970,6 +1970,11 @@ immutable@^4.0.0:
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381"
integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw== integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==
immutable@^5.0.2:
version "5.0.3"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1"
integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==
import-fresh@^3.2.1: import-fresh@^3.2.1:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@ -2656,15 +2661,16 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.1.4" is-regex "^1.1.4"
sass@1.80.5: sass@1.82.0:
version "1.80.5" version "1.82.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.5.tgz#0ba965223d44df22497f2966b498cf5c453fae8f" resolved "https://registry.yarnpkg.com/sass/-/sass-1.82.0.tgz#30da277af3d0fa6042e9ceabd0d984ed6d07df70"
integrity sha512-TQd2aoQl/+zsxRMEDSxVdpPIqeq9UFc6pr7PzkugiTx3VYCFPUaa3P4RrBQsqok4PO200Vkz0vXQBNlg7W907g== integrity sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q==
dependencies: dependencies:
"@parcel/watcher" "^2.4.1"
chokidar "^4.0.0" chokidar "^4.0.0"
immutable "^4.0.0" immutable "^5.0.2"
source-map-js ">=0.6.2 <2.0.0" source-map-js ">=0.6.2 <2.0.0"
optionalDependencies:
"@parcel/watcher" "^2.4.1"
sass@^1.71.1: sass@^1.71.1:
version "1.77.8" version "1.77.8"
@ -2864,13 +2870,13 @@ toggle-selection@^1.0.6:
resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
tom-select@2.3.1: tom-select@2.4.1:
version "2.3.1" version "2.4.1"
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.3.1.tgz#df338d9082874cd0bceb3bee87ed0184447c47f1" resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.1.tgz#6a0b6df8af3df7b09b22dd965eb75ce4d1c547bc"
integrity sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg== integrity sha512-adI8H8+wk8RRzHYLQ3bXSk2Q+FAq/kzAATrcWlJ2fbIrEzb0VkwaXzKHTAlBwSJrhqbPJvhV/0eypFkED/nAug==
dependencies: dependencies:
"@orchidjs/sifter" "^1.0.3" "@orchidjs/sifter" "^1.1.0"
"@orchidjs/unicode-variants" "^1.0.4" "@orchidjs/unicode-variants" "^1.1.2"
ts-api-utils@^1.3.0: ts-api-utils@^1.3.0:
version "1.3.0" version "1.3.0"

View File

@ -1,3 +1,3 @@
version: "4.1.7" version: "4.1.8"
edition: "Community" edition: "Community"
published: "2024-11-21" published: "2024-12-12"

View File

@ -5,7 +5,7 @@
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %} {% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row">
<div class="col-5"> <div class="col-5">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Config Template" %}</h2> <h2 class="card-header">{% trans "Config Template" %}</h2>
@ -48,19 +48,28 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="card"> {% if config_template %}
<h2 class="card-header d-flex justify-content-between"> {% if rendered_config %}
{% trans "Rendered Config" %} <div class="card">
<a href="?export=True" class="btn btn-primary lh-1" role="button"> <h2 class="card-header d-flex justify-content-between">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %} {% trans "Rendered Config" %}
</a> <a href="?export=True" class="btn btn-primary lh-1" role="button">
</h2> <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
{% if config_template %} </a>
<pre class="card-body">{{ rendered_config }}</pre> </h2>
<pre class="card-body">{{ rendered_config }}</pre>
</div>
{% else %} {% else %}
<div class="card-body text-muted">{% trans "No configuration template found" %}</div> <div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% endif %} {% endif %}
</div> {% else %}
<div class="alert alert-info">
{% trans "No configuration template has been assigned for this device." %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -5,7 +5,7 @@
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %} {% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row">
<div class="col-5"> <div class="col-5">
<div class="card"> <div class="card">
<h2 class="card-header">{% trans "Config Template" %}</h2> <h2 class="card-header">{% trans "Config Template" %}</h2>
@ -48,19 +48,28 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="card"> {% if config_template %}
<h2 class="card-header d-flex justify-content-between"> {% if rendered_config %}
{% trans "Rendered Config" %} <div class="card">
<a href="?export=True" class="btn btn-primary lh-1" role="button"> <h2 class="card-header d-flex justify-content-between">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %} {% trans "Rendered Config" %}
</a> <a href="?export=True" class="btn btn-primary lh-1" role="button">
</h2> <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
{% if config_template %} </a>
<pre class="card-body">{{ rendered_config }}</pre> </h2>
<pre class="card-body">{{ rendered_config }}</pre>
</div>
{% else %} {% else %}
<div class="card-body text-muted">{% trans "No configuration template found" %}</div> <div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% endif %} {% endif %}
</div> {% else %}
<div class="alert alert-info">
{% trans "No configuration template has been assigned for this virtual machine." %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -144,6 +144,19 @@ class APIPaginationTestCase(APITestCase):
self.assertIsNone(response.data['previous']) self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), page_size) self.assertEqual(len(response.data['results']), page_size)
@override_settings(MAX_PAGE_SIZE=30)
def test_default_page_size_with_small_max_page_size(self):
response = self.client.get(self.url, format='json', **self.header)
page_size = get_config().MAX_PAGE_SIZE
paginate_count = get_config().PAGINATE_COUNT
self.assertLess(page_size, 100, "Default page size not sufficient for data set")
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 100)
self.assertTrue(response.data['next'].endswith(f'?limit={paginate_count}&offset={paginate_count}'))
self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), paginate_count)
def test_custom_page_size(self): def test_custom_page_size(self):
response = self.client.get(f'{self.url}?limit=10', format='json', **self.header) response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)
@ -153,15 +166,15 @@ class APIPaginationTestCase(APITestCase):
self.assertIsNone(response.data['previous']) self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), 10) self.assertEqual(len(response.data['results']), 10)
@override_settings(MAX_PAGE_SIZE=20) @override_settings(MAX_PAGE_SIZE=80)
def test_max_page_size(self): def test_max_page_size(self):
response = self.client.get(f'{self.url}?limit=0', format='json', **self.header) response = self.client.get(f'{self.url}?limit=0', format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 100) self.assertEqual(response.data['count'], 100)
self.assertTrue(response.data['next'].endswith('?limit=20&offset=20')) self.assertTrue(response.data['next'].endswith('?limit=80&offset=80'))
self.assertIsNone(response.data['previous']) self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), 20) self.assertEqual(len(response.data['results']), 80)
@override_settings(MAX_PAGE_SIZE=0) @override_settings(MAX_PAGE_SIZE=0)
def test_max_page_size_disabled(self): def test_max_page_size_disabled(self):

View File

@ -1,5 +1,3 @@
import traceback
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Prefetch, Sum from django.db.models import Prefetch, Sum
@ -425,7 +423,8 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
# If a direct export has been requested, return the rendered template content as a # If a direct export has been requested, return the rendered template content as a
# downloadable file. # downloadable file.
if request.GET.get('export'): if request.GET.get('export'):
response = HttpResponse(context['rendered_config'], content_type='text') content = context['rendered_config'] or context['error_message']
response = HttpResponse(content, content_type='text')
filename = f"{instance.name or 'config'}.txt" filename = f"{instance.name or 'config'}.txt"
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response return response
@ -443,17 +442,18 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
# Render the config template # Render the config template
rendered_config = None rendered_config = None
error_message = None
if config_template := instance.get_config_template(): if config_template := instance.get_config_template():
try: try:
rendered_config = config_template.render(context=context_data) rendered_config = config_template.render(context=context_data)
except TemplateError as e: except TemplateError as e:
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e)) error_message = _("An error occurred while rendering the template: {error}").format(error=e)
rendered_config = traceback.format_exc()
return { return {
'config_template': config_template, 'config_template': config_template,
'context_data': context_data, 'context_data': context_data,
'rendered_config': rendered_config, 'rendered_config': rendered_config,
'error_message': error_message,
} }

View File

@ -1,4 +1,4 @@
Django==5.0.9 Django==5.0.10
django-cors-headers==4.6.0 django-cors-headers==4.6.0
django-debug-toolbar==4.4.6 django-debug-toolbar==4.4.6
django-filter==24.3 django-filter==24.3
@ -14,16 +14,16 @@ django-taggit==6.1.0
django-tables2==2.7.0 django-tables2==2.7.0
django-timezone-field==7.0 django-timezone-field==7.0
djangorestframework==3.15.2 djangorestframework==3.15.2
drf-spectacular==0.27.2 drf-spectacular==0.28.0
drf-spectacular-sidecar==2024.11.1 drf-spectacular-sidecar==2024.12.1
feedparser==6.0.11 feedparser==6.0.11
gunicorn==23.0.0 gunicorn==23.0.0
Jinja2==3.1.4 Jinja2==3.1.4
Markdown==3.7 Markdown==3.7
mkdocs-material==9.5.45 mkdocs-material==9.5.48
mkdocstrings[python-legacy]==0.27.0 mkdocstrings[python-legacy]==0.27.0
netaddr==1.3.0 netaddr==1.3.0
nh3==0.2.18 nh3==0.2.19
Pillow==11.0.0 Pillow==11.0.0
psycopg[c,pool]==3.2.3 psycopg[c,pool]==3.2.3
PyYAML==6.0.2 PyYAML==6.0.2
@ -31,8 +31,8 @@ requests==2.32.3
rq==2.0 rq==2.0
social-auth-app-django==5.4.2 social-auth-app-django==5.4.2
social-auth-core==4.5.4 social-auth-core==4.5.4
strawberry-graphql==0.251.0 strawberry-graphql==0.253.1
strawberry-graphql-django==0.50.0 strawberry-graphql-django==0.51.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.7.0 tablib==3.7.0
tzdata==2024.2 tzdata==2024.2