Compare commits
85
Commits
zone-template
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284e5bd4a0
|
||
|
|
8f7e34a762
|
||
|
|
ff27b75101
|
||
|
|
571523354e
|
||
|
|
bc46a33201
|
||
|
|
782c9465bf
|
||
|
|
0e38b91fc5
|
||
|
|
ad42f86ed7
|
||
|
|
f0ecdb41bc
|
||
|
|
a8a4c76d98
|
||
|
|
3b589f6085
|
||
|
|
58937e1105
|
||
|
|
d43aa43186
|
||
|
|
cb2784eec5
|
||
|
|
d7c97df832
|
||
|
|
d535e1b080
|
||
|
|
284dfdbdf4
|
||
|
|
d3b6024cd8
|
||
|
|
3ccce3849e
|
||
|
|
be0e621b28
|
||
|
|
1e128e8d9b
|
||
|
|
1d0ba86d53
|
||
|
|
fe62d13d3c
|
||
|
|
d2155bc065
|
||
|
|
478871838a
|
||
|
|
16f76a9575
|
||
|
|
9e065f37c2
|
||
|
|
5c275dd1e7
|
||
|
|
47acd88923 | ||
|
|
4680f4b809 | ||
|
|
f3b0df1937 | ||
|
|
fdfce5ca6d | ||
|
|
f4950c6388 | ||
|
|
94c2b03138 | ||
|
|
1fb0b8385f | ||
|
|
a7dba1fe2e | ||
|
|
8b002d4620 | ||
|
|
a2fcc7321d | ||
|
|
a5c06bdce5 | ||
|
|
0bc8daeabc | ||
|
|
e96948080f | ||
|
|
0123dd1e3b | ||
|
|
7957459e06 | ||
|
|
4269569302 | ||
|
|
c935936ddc | ||
|
|
4b7952a6eb | ||
|
|
87d88035d9 | ||
|
|
4c924377cf | ||
|
|
d095674a89 | ||
|
|
7e9b758421 | ||
|
|
87a67dab07 | ||
|
|
55c0a3453f | ||
|
|
675b0e1344 | ||
|
|
c338680787 | ||
|
|
ecc7ec0de8 | ||
|
|
1d7860dfee | ||
|
|
47b69a9ea7 | ||
|
|
7de3e4008c | ||
|
|
54180d6bb5 | ||
|
|
49183fb646 | ||
|
|
cf6b1ad13d | ||
|
|
51b595a11d | ||
|
|
093fae0fea | ||
|
|
b79fdafaa1 | ||
|
|
8fe8a1b311 | ||
|
|
935e38d224 | ||
|
|
2fd8001ba5 | ||
|
|
ca76aaa80b | ||
|
|
456bcb7c50 | ||
|
|
277fde9809 | ||
|
|
257b00be1b | ||
|
|
3659215a2f | ||
|
|
7b47e1d6d9 | ||
|
|
e17bf339e2 | ||
|
|
9d737dcd45 | ||
|
|
898578852d | ||
|
|
2a0744f602 | ||
|
|
15cde2cce4 | ||
|
|
d523b4ae99 | ||
|
|
fdb826d5ac | ||
|
|
bcb4b2e713 | ||
|
|
4daf775adb | ||
|
|
2df1f6ab36 | ||
|
|
5ece0c21f9 | ||
|
|
8946234b10 |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
name: CI
|
||||
|
||||
# Tests and prek on every push and pull request. A push to main that passes both deploys
|
||||
# to sega, while the repository variable DEPLOY_SEGA is true.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# bind9-utils provides named-checkzone, used by the zone sync tests.
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends bind9-utils
|
||||
|
||||
- name: Install mise
|
||||
run: |
|
||||
curl -fsSL https://mise.run | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install pinned tools
|
||||
run: mise install
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: mise exec -- uv sync --locked --no-group prod
|
||||
|
||||
- name: Unit tests
|
||||
run: mise exec -- uv run --no-sync pytest
|
||||
|
||||
# The hooks in prek.toml.
|
||||
prek:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: j178/prek-action@v1
|
||||
|
||||
# sega's authorized_keys sends this key to mdb-deploy, which reads the sha from the ssh
|
||||
# command line, builds that commit on the host and switches to it (edb-infra's
|
||||
# roles/mdb.md).
|
||||
deploy-sega:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_SEGA == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- tests
|
||||
- prek
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
|
||||
eval $(ssh-agent -s)
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||
ssh mdb-deploy@sega.neuf.no "${{ github.sha }}"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Agents
|
||||
|
||||
## Commits
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <subject>`,
|
||||
with the scope optional. The types in use are `feat`, `fix`, `test`, `ci`, `chore`, `style`,
|
||||
`refactor` and `docs`. For example:
|
||||
|
||||
- `fix(zone_sync): thaw zones when the sync is killed`
|
||||
- `ci: one workflow for tests, prek and the deploy to sega`
|
||||
@@ -1,21 +1,46 @@
|
||||
A Django app for managing computer equipment.
|
||||
|
||||
## Installation
|
||||
sudo apt install libmysqlclient-dev python-dev libldap2-dev libsasl2-dev bind9utils
|
||||
virtualenv venv
|
||||
. venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py runserver
|
||||
|
||||
Install system dependencies:
|
||||
|
||||
sudo apt install python-dev libldap2-dev libsasl2-dev bind9-utils
|
||||
|
||||
Use [mise](https://mise.jdx.dev/) to manage Python and uv:
|
||||
|
||||
mise trust
|
||||
mise install
|
||||
|
||||
Install Python dependencies with uv:
|
||||
|
||||
uv sync
|
||||
|
||||
Run migrations and start the development server:
|
||||
|
||||
uv run python manage.py migrate
|
||||
uv run python manage.py runserver
|
||||
|
||||
`mise.toml` sets `DJANGO_DEBUG=1`; without it `DEBUG` is off. Local overrides go in
|
||||
`mdbsite/local_settings.py`.
|
||||
|
||||
## Production
|
||||
|
||||
MDB runs on sega under edb-infra's `mdb` role (`roles/mdb.md` there): gunicorn from a
|
||||
release directory per commit, its settings in the file `MDB_SETTINGS` names
|
||||
(`/etc/mdb/settings.py`), which `mdbsite/settings.py` executes last. A push to `main` that
|
||||
passes the tests and prek deploys itself while the repository variable `DEPLOY_SEGA` is `true`
|
||||
(`.gitea/workflows/ci.yaml`). The DNS and DHCP sync runs as root on sega:
|
||||
`sudo mdb-sync`.
|
||||
|
||||
## Development tasks
|
||||
|
||||
Dump test data:
|
||||
|
||||
python manage.py dumpdata mdb --natural-foreign --indent=4 > mdb/fixtures/test_data.json
|
||||
uv run python manage.py dumpdata mdb --natural-foreign --indent=4 > mdb/fixtures/test_data.json
|
||||
|
||||
## Features
|
||||
|
||||
* Creates DHCPD configuration files
|
||||
* Creates DNS zone files for BIND
|
||||
* Creates PXE config files (with random secret for use with Puppet autosign)
|
||||
* Partial IPv6 support
|
||||
- Creates DHCPD configuration files
|
||||
- Creates DNS zone files for BIND
|
||||
- Netboots hosts marked PXE installable, by setting their DHCP boot filename
|
||||
- Partial IPv6 support
|
||||
@@ -5,4 +5,5 @@ import sys
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = 'mdb.apps.MdbAppConfig'
|
||||
+237
-51
@@ -1,8 +1,45 @@
|
||||
from django.contrib import admin, messages
|
||||
import ipaddress
|
||||
|
||||
from mdb.models import Ip6Address, Interface, Ip4Address, DhcpOption, DhcpCustomField, DomainSrvRecord, DomainTxtRecord, \
|
||||
DomainCnameRecord, DomainARecord, Domain, Host, Ip4Subnet, Ip6Subnet, Nameserver, MailExchange, OperatingSystem, \
|
||||
HostType, DhcpConfig, DomainAAAARecord
|
||||
from django.conf import settings
|
||||
from django.contrib import admin, messages
|
||||
from django.db import models
|
||||
from django.db.models import Count
|
||||
from django.forms import NumberInput
|
||||
|
||||
from mdb.forms import InterfaceForm, SerialNumberInput
|
||||
from mdb.models import (
|
||||
DhcpConfig,
|
||||
DhcpCustomField,
|
||||
DhcpOption,
|
||||
Domain,
|
||||
DomainAAAARecord,
|
||||
DomainARecord,
|
||||
DomainCnameRecord,
|
||||
DomainSrvRecord,
|
||||
DomainTxtRecord,
|
||||
Host,
|
||||
HostType,
|
||||
Interface,
|
||||
Ip4Address,
|
||||
Ip4Subnet,
|
||||
Ip6Address,
|
||||
Ip6Subnet,
|
||||
MailExchange,
|
||||
Nameserver,
|
||||
OperatingSystem,
|
||||
)
|
||||
|
||||
admin.site.site_header = settings.ADMIN_SITE_NAME
|
||||
|
||||
|
||||
class SerialFieldAdminMixin:
|
||||
"""Render the ``domain_serial`` field with the wide serial widget + button."""
|
||||
|
||||
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
||||
formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||
if db_field.name == "domain_serial":
|
||||
formfield.widget = SerialNumberInput()
|
||||
return formfield
|
||||
|
||||
|
||||
class Ip6AddressInline(admin.TabularInline):
|
||||
@@ -14,64 +51,123 @@ class InterfaceInline(admin.TabularInline):
|
||||
inlines = [Ip6AddressInline]
|
||||
model = Interface
|
||||
extra = 0
|
||||
form = InterfaceForm
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super(InterfaceInline, self).get_queryset(request).select_related()
|
||||
return super().get_queryset(request).select_related("host", "domain")
|
||||
|
||||
|
||||
@admin.register(Host)
|
||||
class HostAdmin(admin.ModelAdmin):
|
||||
ordering = ('hostname',)
|
||||
ordering = ("hostname",)
|
||||
inlines = [InterfaceInline]
|
||||
list_display = ['hostname', 'owner', 'host_type', 'location', 'mac_addresses', 'ip_addresses', 'in_domain',
|
||||
'pxe_installable', 'ipv6_enabled']
|
||||
list_filter = ['host_type', 'owner', 'location', 'pxe_installable']
|
||||
readonly_fields = ['kerberos_principal_name', 'kerberos_principal_created_date',
|
||||
'kerberos_principal_created']
|
||||
search_fields = ['hostname', 'location', 'interface__macaddr', 'interface__ip4address__address']
|
||||
list_display = [
|
||||
"hostname",
|
||||
"owner",
|
||||
"host_type",
|
||||
"location",
|
||||
"mac_addresses",
|
||||
"ip_addresses",
|
||||
"in_domain",
|
||||
"pxe_installable",
|
||||
"ipv6_enabled",
|
||||
"allow_email",
|
||||
]
|
||||
list_filter = ["host_type", "owner", "location", "pxe_installable", "allow_email"]
|
||||
readonly_fields = [
|
||||
"kerberos_principal_name",
|
||||
"kerberos_principal_created_date",
|
||||
"kerberos_principal_created",
|
||||
]
|
||||
search_fields = [
|
||||
"hostname",
|
||||
"location",
|
||||
"interface__macaddr",
|
||||
"interface__ip4address__address",
|
||||
]
|
||||
fieldsets = (
|
||||
('Owner Information', {
|
||||
'fields': ('owner', 'location', 'description')
|
||||
}),
|
||||
('Hardware and Software Information', {
|
||||
'fields': (('hostname', 'host_type'), ('pxe_key', 'pxe_installable'), ('brand', 'model'), 'serial_number', ('operating_system', 'virtual'))
|
||||
}),
|
||||
# FIXME: Not in use
|
||||
# ('Domain and Kerberos Information', {
|
||||
# 'description': 'If this host is a member of the LDAP domain, you need to tick the request kerberos principal checkbox. A principal will then be created for the host.',
|
||||
# 'classes': ['collapse'],
|
||||
# 'fields': ('request_kerberos_principal', 'kerberos_principal_created',
|
||||
# ('kerberos_principal_name', 'kerberos_principal_created_date'))
|
||||
# }),
|
||||
("Owner Information", {"fields": ("owner", "location", "description")}),
|
||||
(
|
||||
"Hardware and Software Information",
|
||||
{
|
||||
"fields": (
|
||||
("hostname", "host_type"),
|
||||
("pxe_key", "pxe_installable"),
|
||||
("brand", "model"),
|
||||
"serial_number",
|
||||
("operating_system", "virtual"),
|
||||
"allow_email",
|
||||
)
|
||||
actions = ['set_installable']
|
||||
},
|
||||
),
|
||||
)
|
||||
actions = ["set_installable"]
|
||||
|
||||
def _get_host_warning_message(self, host, ifs):
|
||||
ifs_str = ', '.join([_if.name for _if in ifs])
|
||||
return 'Host {} might not be installable since interface(s) {} has no IP set.'.format(host, ifs_str)
|
||||
def mac_addresses(self, host):
|
||||
addresses = host.interface_set.filter(macaddr__isnull=False).values_list(
|
||||
"macaddr", flat=True
|
||||
)
|
||||
return ", ".join(addresses)
|
||||
|
||||
def ip_addresses(self, host):
|
||||
addresses = host.interface_set.filter(ip4address__isnull=False).values_list(
|
||||
"ip4address__address", flat=True
|
||||
)
|
||||
return ", ".join(addresses)
|
||||
|
||||
@admin.display(description="in domains")
|
||||
def in_domain(self, host):
|
||||
domains = host.interface_set.values_list("domain__domain_name", flat=True)
|
||||
return ",".join(domains)
|
||||
|
||||
@admin.display(boolean=True)
|
||||
def ipv6_enabled(self, host):
|
||||
ipv6_ifs = host.interface_set.annotate(num_ipv6=Count("ip6address")).filter(
|
||||
num_ipv6__gt=0
|
||||
)
|
||||
return ipv6_ifs.exists()
|
||||
|
||||
# FIXME: custom filter to sort custom field
|
||||
|
||||
@staticmethod
|
||||
def _get_host_warning_message(host, ifs):
|
||||
ifs_str = ", ".join([_if.name for _if in ifs])
|
||||
return f"Host {host} might not be installable since interface(s) {ifs_str} has no IP set."
|
||||
|
||||
@admin.action(description="Mark selected hosts as PXE installable")
|
||||
def set_installable(self, request, queryset):
|
||||
invalid_ifs_per_host = []
|
||||
|
||||
for obj in queryset:
|
||||
invalid_ifs = obj.interface_set.filter(ip4address__isnull=True)
|
||||
if invalid_ifs.exists():
|
||||
invalid_ifs_per_host.append(self._get_host_warning_message(obj, invalid_ifs))
|
||||
invalid_ifs_per_host.append(
|
||||
self._get_host_warning_message(obj, invalid_ifs)
|
||||
)
|
||||
obj.pxe_installable = True
|
||||
obj.save() # triggers post save signal
|
||||
|
||||
if invalid_ifs_per_host:
|
||||
self.message_user(request, message='\n'.join(invalid_ifs_per_host), level=messages.WARNING)
|
||||
|
||||
set_installable.short_description = "Mark selected hosts as PXE installable"
|
||||
self.message_user(
|
||||
request, message="\n".join(invalid_ifs_per_host), level=messages.WARNING
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super(HostAdmin, self).get_queryset(request).select_related()
|
||||
return (
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.select_related("host_type", "operating_system")
|
||||
)
|
||||
|
||||
|
||||
class Ip4AddressInline(admin.TabularInline):
|
||||
model = Ip4Address
|
||||
extra = 0
|
||||
readonly_fields = ['address', 'assigned_to_host']
|
||||
readonly_fields = ["address", "assigned_to_host"]
|
||||
|
||||
@admin.display(description="Assigned to Host")
|
||||
def assigned_to_host(self, addr):
|
||||
return addr.interface.host
|
||||
|
||||
|
||||
class DhcpOptionInline(admin.TabularInline):
|
||||
@@ -84,13 +180,76 @@ class DhcpCustomFieldInline(admin.TabularInline):
|
||||
extra = 0
|
||||
|
||||
|
||||
class SubnetAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'network', 'netmask', 'num_addresses', 'broadcast_address', 'first_address', 'last_address']
|
||||
@admin.register(Ip4Subnet)
|
||||
class Ip4SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
|
||||
list_display = [
|
||||
"name",
|
||||
"network",
|
||||
"netmask",
|
||||
"domain_name",
|
||||
"num_addresses",
|
||||
"broadcast_address",
|
||||
"first_address",
|
||||
"last_address",
|
||||
]
|
||||
readonly_fields = ["domain_name"]
|
||||
inlines = [DhcpOptionInline, DhcpCustomFieldInline, Ip4AddressInline]
|
||||
|
||||
@admin.display(description="# addresses")
|
||||
def num_addresses(self, ip4subnet):
|
||||
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
|
||||
return subnet.num_addresses
|
||||
|
||||
@admin.display(description="broadcast")
|
||||
def broadcast_address(self, ip4subnet):
|
||||
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
|
||||
return subnet.broadcast_address
|
||||
|
||||
@admin.display(description="first address")
|
||||
def first_address(self, ip4subnet):
|
||||
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
|
||||
return next(subnet.hosts())
|
||||
|
||||
@admin.display(description="last address")
|
||||
def last_address(self, ip4subnet):
|
||||
subnet = ipaddress.IPv4Network(ip4subnet.network + "/" + ip4subnet.netmask)
|
||||
return list(subnet.hosts())[-1]
|
||||
|
||||
def get_queryset(self, request):
|
||||
# TODO: optimize
|
||||
return super(SubnetAdmin, self).get_queryset(request)
|
||||
return super().get_queryset(request)
|
||||
|
||||
|
||||
@admin.register(Ip6Subnet)
|
||||
class Ip6SubnetAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
|
||||
list_display = [
|
||||
"name",
|
||||
"network",
|
||||
"netmask",
|
||||
"domain_name",
|
||||
"num_addresses",
|
||||
"first_address",
|
||||
"last_address",
|
||||
]
|
||||
readonly_fields = ["domain_name"]
|
||||
|
||||
def _network(self, ip6subnet):
|
||||
return ipaddress.IPv6Network(
|
||||
f"{ip6subnet.network}::/{ip6subnet.netmask:d}", strict=False
|
||||
)
|
||||
|
||||
@admin.display(description="# addresses")
|
||||
def num_addresses(self, ip6subnet):
|
||||
return self._network(ip6subnet).num_addresses
|
||||
|
||||
@admin.display(description="first address")
|
||||
def first_address(self, ip6subnet):
|
||||
return self._network(ip6subnet).network_address
|
||||
|
||||
@admin.display(description="last address")
|
||||
def last_address(self, ip6subnet):
|
||||
# subnet[-1] is O(1); never materialize hosts() for a /64.
|
||||
return self._network(ip6subnet)[-1]
|
||||
|
||||
|
||||
class DomainSrvRecordInline(admin.TabularInline):
|
||||
@@ -118,29 +277,56 @@ class DomainAAAARecordInline(admin.TabularInline):
|
||||
extra = 0
|
||||
|
||||
|
||||
class DomainAdmin(admin.ModelAdmin):
|
||||
inlines = [DomainSrvRecordInline, DomainTxtRecordInline, DomainARecordInline, DomainAAAARecordInline,
|
||||
DomainCnameRecordInline]
|
||||
list_display = ['domain_name', 'domain_soa', 'domain_admin', 'num_records', 'domain_ipaddr', 'domain_ip6addr']
|
||||
search_fields = ['domain_name']
|
||||
@admin.register(Domain)
|
||||
class DomainAdmin(SerialFieldAdminMixin, admin.ModelAdmin):
|
||||
inlines = [
|
||||
DomainSrvRecordInline,
|
||||
DomainTxtRecordInline,
|
||||
DomainARecordInline,
|
||||
DomainAAAARecordInline,
|
||||
DomainCnameRecordInline,
|
||||
]
|
||||
list_display = [
|
||||
"domain_name",
|
||||
"domain_soa",
|
||||
"domain_admin",
|
||||
"num_records",
|
||||
"domain_ipaddr",
|
||||
"domain_ip6addr",
|
||||
]
|
||||
search_fields = ["domain_name"]
|
||||
formfield_overrides = {
|
||||
models.IntegerField: {"widget": NumberInput(attrs={"style": "width: 10em"})},
|
||||
}
|
||||
|
||||
@admin.display(description="Num Records")
|
||||
def num_records(self, domain):
|
||||
host_a_records = Host.objects.filter(interface__domain=domain).count()
|
||||
size = {
|
||||
"CNAME": domain.domaincnamerecord_set.count(),
|
||||
"SRV": domain.domainsrvrecord_set.count(),
|
||||
"TXT": domain.domaintxtrecord_set.count(),
|
||||
"A": domain.domainarecord_set.count() + host_a_records,
|
||||
}
|
||||
return ", ".join([f"{k}: {v}" for k, v in size.items()])
|
||||
|
||||
|
||||
@admin.register(HostType)
|
||||
class HostTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ['host_type', 'description', 'num_members']
|
||||
list_display = ["host_type", "description", "num_members"]
|
||||
|
||||
|
||||
@admin.register(OperatingSystem)
|
||||
class OperatingSystemAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'version', 'arch']
|
||||
list_display = [
|
||||
"name",
|
||||
"version",
|
||||
"arch",
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(Domain, DomainAdmin)
|
||||
admin.site.register(Host, HostAdmin)
|
||||
admin.site.register(Ip4Subnet, SubnetAdmin)
|
||||
admin.site.register(Ip6Subnet)
|
||||
admin.site.register(Ip6Address)
|
||||
admin.site.register(Nameserver)
|
||||
admin.site.register(MailExchange)
|
||||
admin.site.register(OperatingSystem, OperatingSystemAdmin)
|
||||
admin.site.register(HostType, HostTypeAdmin)
|
||||
admin.site.register(DhcpConfig)
|
||||
admin.site.register(DhcpOption)
|
||||
+23
-11
@@ -1,7 +1,8 @@
|
||||
from mdb.models import Host
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from mdb.models import Host
|
||||
|
||||
|
||||
class HostPXEValidateSerializer(serializers.Serializer):
|
||||
certname = serializers.CharField(required=True)
|
||||
@@ -9,13 +10,15 @@ class HostPXEValidateSerializer(serializers.Serializer):
|
||||
host = None
|
||||
|
||||
def _get_host_by_fqdn(self, attrs):
|
||||
fqdn = attrs['certname']
|
||||
host_name = fqdn.split('.')[0]
|
||||
domain = '.'.join(fqdn.split('.')[1:])
|
||||
fqdn = attrs["certname"]
|
||||
host_name = fqdn.split(".")[0]
|
||||
domain = ".".join(fqdn.split(".")[1:])
|
||||
|
||||
try:
|
||||
return Host.objects.get(hostname=host_name, interface__domain__domain_name=domain)
|
||||
except (Host.DoesNotExist, Host.MultipleObjectsReturned):
|
||||
return Host.objects.get(
|
||||
hostname=host_name, interface__domain__domain_name=domain
|
||||
)
|
||||
except Host.DoesNotExist, Host.MultipleObjectsReturned:
|
||||
return None
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -23,16 +26,24 @@ class HostPXEValidateSerializer(serializers.Serializer):
|
||||
host = self._get_host_by_fqdn(attrs)
|
||||
|
||||
if host is None:
|
||||
raise ValidationError('Host \'{}\' not found.'.format(attrs['certname']))
|
||||
raise ValidationError("Host '{}' not found.".format(attrs["certname"]))
|
||||
|
||||
if not host.pxe_installable:
|
||||
raise ValidationError('Host \'{}\' is marked as not installable via PXE.'.format(attrs['certname']))
|
||||
raise ValidationError(
|
||||
"Host '{}' is marked as not installable via PXE.".format(
|
||||
attrs["certname"]
|
||||
)
|
||||
)
|
||||
|
||||
if not host.pxe_key:
|
||||
raise ValidationError('Host \'{}\' has no pxe_key.'.format(attrs['certname']))
|
||||
raise ValidationError("Host '{}' has no pxe_key.".format(attrs["certname"]))
|
||||
|
||||
if host.pxe_key != attrs['pxe_key']:
|
||||
raise ValidationError('Supplied pxe_key does not match host \'{}\'.'.format(attrs['certname']))
|
||||
if host.pxe_key != attrs["pxe_key"]:
|
||||
raise ValidationError(
|
||||
"Supplied pxe_key '{}' does not match host '{}'.".format(
|
||||
attrs["pxe_key"], attrs["certname"]
|
||||
)
|
||||
)
|
||||
|
||||
self.host = host
|
||||
|
||||
@@ -41,6 +52,7 @@ class HostPXEValidateSerializer(serializers.Serializer):
|
||||
def create(self, validated_data):
|
||||
# Make requests kind of idempotent by only allowing 1 request / installation
|
||||
self.host.pxe_installable = False
|
||||
self.host.pxe_key = ""
|
||||
self.host.save()
|
||||
|
||||
return self.host
|
||||
+2
-2
@@ -15,6 +15,6 @@ class HostPXEValidate(APIView):
|
||||
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
host = serializer.save() # sets Host.pxe_installable=False
|
||||
serializer.save() # sets Host.pxe_installable=False
|
||||
|
||||
return Response({'valid_pxe_key': True})
|
||||
return Response({"valid_pxe_key": True})
|
||||
+3
-3
@@ -2,9 +2,9 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class MdbAppConfig(AppConfig):
|
||||
name = 'mdb'
|
||||
verbose_name = 'Machine Database'
|
||||
name = "mdb"
|
||||
verbose_name = "Machine Database"
|
||||
|
||||
def ready(self):
|
||||
# all models are loaded, now attach signals
|
||||
import mdb.signals
|
||||
import mdb.signals # noqa
|
||||
@@ -34,7 +34,7 @@
|
||||
"domain_active_serial": 0,
|
||||
"domain_ip6addr": "::1",
|
||||
"domain_refresh": 28800,
|
||||
"created_date": "2015-10-03T13:02:53.581",
|
||||
"created_date": "2015-10-03T13:02:53.581Z",
|
||||
"domain_soa": "sega.neuf.no",
|
||||
"domain_admin": "sysadmin@neuf.no",
|
||||
"domain_minimum_ttl": 86400,
|
||||
@@ -51,7 +51,7 @@
|
||||
{
|
||||
"fields": {
|
||||
"domain": 1,
|
||||
"created_date": "2015-10-04T17:13:46.810",
|
||||
"created_date": "2015-10-04T17:13:46.810Z",
|
||||
"name": "www",
|
||||
"target": "edb.technology."
|
||||
},
|
||||
@@ -61,7 +61,7 @@
|
||||
{
|
||||
"fields": {
|
||||
"domain": 1,
|
||||
"created_date": "2015-10-04T17:13:46.807",
|
||||
"created_date": "2015-10-04T17:13:46.807Z",
|
||||
"name": "test",
|
||||
"target": "127.0.0.1"
|
||||
},
|
||||
@@ -71,7 +71,7 @@
|
||||
{
|
||||
"fields": {
|
||||
"domain": 1,
|
||||
"created_date": "2015-10-04T17:13:46.809",
|
||||
"created_date": "2015-10-04T17:13:46.809Z",
|
||||
"name": "test",
|
||||
"target": "::1"
|
||||
},
|
||||
@@ -103,7 +103,7 @@
|
||||
"netmask": 64,
|
||||
"domain_active_serial": 0,
|
||||
"domain_refresh": 28800,
|
||||
"created_date": "2015-10-04T12:50:12.248",
|
||||
"created_date": "2015-10-04T12:50:12.248Z",
|
||||
"domain_soa": "sega.neuf.no",
|
||||
"domain_admin": "sysadmin@neuf.no",
|
||||
"domain_minimum_ttl": 86400,
|
||||
@@ -132,7 +132,7 @@
|
||||
"netmask": "255.255.255.0",
|
||||
"domain_active_serial": 0,
|
||||
"domain_refresh": 28800,
|
||||
"created_date": "2015-10-03T13:08:17.765",
|
||||
"created_date": "2015-10-03T13:08:17.765Z",
|
||||
"domain_soa": "sega.neuf.no",
|
||||
"domain_admin": "sysadmin@neuf.no",
|
||||
"domain_minimum_ttl": 86400,
|
||||
@@ -2738,6 +2738,15 @@
|
||||
"model": "mdb.operatingsystem",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"arch": "x86_64",
|
||||
"name": "Ubuntu",
|
||||
"version": "16.04-beta-final"
|
||||
},
|
||||
"model": "mdb.operatingsystem",
|
||||
"pk": 2
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"kerberos_principal_name": "",
|
||||
@@ -2751,7 +2760,7 @@
|
||||
"pxe_installable": true,
|
||||
"kerberos_principal_created_date": null,
|
||||
"location": "kak",
|
||||
"created_date": "2015-10-03T13:23:37.061",
|
||||
"created_date": "2015-10-03T13:23:37.061Z",
|
||||
"owner": "edb",
|
||||
"serial_number": "12345678",
|
||||
"model": "OptiPlex 760",
|
||||
@@ -2770,7 +2779,7 @@
|
||||
"host": 1,
|
||||
"macaddr": "f0:de:f1:58:be:1a",
|
||||
"ip4address": 2,
|
||||
"created_date": "2015-10-03T13:23:37.063"
|
||||
"created_date": "2015-10-03T13:23:37.063Z"
|
||||
},
|
||||
"model": "mdb.interface",
|
||||
"pk": 1
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import ModelChoiceField, NumberInput
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from mdb.models import Interface, Ip4Address
|
||||
|
||||
|
||||
class SerialNumberInput(NumberInput):
|
||||
"""Number input for DNS SOA serials (YYYYMMDDXX).
|
||||
|
||||
Renders wide enough to show the full 10-digit serial and adds a button
|
||||
that prefills the field with the next serial: today's first, or one more than
|
||||
the current serial when that is higher.
|
||||
"""
|
||||
|
||||
def __init__(self, attrs=None):
|
||||
default_attrs = {"style": "width: 12em"}
|
||||
if attrs:
|
||||
default_attrs.update(attrs)
|
||||
super().__init__(default_attrs)
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
field = super().render(name, value, attrs, renderer)
|
||||
field_id = (attrs or {}).get("id", f"id_{name}")
|
||||
extra = """
|
||||
<button type="button" class="button" style="margin-left: 0.5em;"
|
||||
onclick="mdbBumpSerial('__ID__')">Today + next serial</button>
|
||||
<script>
|
||||
if (typeof window.mdbBumpSerial === 'undefined') {
|
||||
window.mdbBumpSerial = function (inputId) {
|
||||
var input = document.getElementById(inputId);
|
||||
if (!input) { return; }
|
||||
var now = new Date();
|
||||
var today = '' + now.getFullYear()
|
||||
+ String(now.getMonth() + 1).padStart(2, '0')
|
||||
+ String(now.getDate()).padStart(2, '0');
|
||||
// As mdb.utils.format_domain_serial_and_add_one: never lower than the current
|
||||
// serial, so past XX = 99 it carries into the date.
|
||||
var current = parseInt((input.value || '').trim(), 10);
|
||||
var next = parseInt(today + '01', 10);
|
||||
if (!isNaN(current) && current + 1 > next) {
|
||||
next = current + 1;
|
||||
}
|
||||
input.value = String(next);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
""".replace("__ID__", field_id)
|
||||
return mark_safe(field + extra)
|
||||
|
||||
|
||||
class Ip4ModelChoiceField(ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
if not hasattr(obj, "interface"):
|
||||
return obj.address
|
||||
|
||||
return f"{obj.address} ({obj.interface.host.hostname})"
|
||||
|
||||
|
||||
class InterfaceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = "__all__"
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
# Missing when the field itself failed validation, and the address is optional.
|
||||
macaddr = cleaned_data.get("macaddr")
|
||||
ip4address = cleaned_data.get("ip4address")
|
||||
if not macaddr or ip4address is None:
|
||||
return cleaned_data
|
||||
|
||||
# dhcpd matches MAC addresses whatever their case.
|
||||
clashes = Interface.objects.filter(
|
||||
macaddr__iexact=macaddr, ip4address__subnet=ip4address.subnet
|
||||
).exclude(pk=self.instance.pk)
|
||||
if clashes.exists():
|
||||
raise ValidationError("Macaddr already in this subnet")
|
||||
return cleaned_data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Join Ip4Address with tables interface and host,
|
||||
# before printing the select labels that walk those two relations.
|
||||
queryset = Ip4Address.objects.select_related("interface", "interface__host")
|
||||
# Not required, as on the model: a host can have interfaces without IPv4.
|
||||
self.fields["ip4address"] = Ip4ModelChoiceField(
|
||||
queryset=queryset, required=False
|
||||
)
|
||||
@@ -1,106 +0,0 @@
|
||||
from __future__ import with_statement
|
||||
|
||||
import os
|
||||
import sys
|
||||
from difflib import context_diff
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.mail import mail_admins
|
||||
from optparse import make_option
|
||||
try:
|
||||
from subprocess import getstatusoutput
|
||||
except ImportError:
|
||||
from commands import getstatusoutput
|
||||
|
||||
from mdb.models import DhcpConfig
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
args = '--output <path> [--force] [--restart]'
|
||||
help = 'Generates the DHCP configuration'
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--output',
|
||||
action='store',
|
||||
dest='output',
|
||||
default=None,
|
||||
help='Path to dhcpd.conf, defaults to stdout'),
|
||||
make_option('--force',
|
||||
action='store_true',
|
||||
dest='force',
|
||||
default=False,
|
||||
help='Write configuration even if serial is unchanged.'),
|
||||
make_option('--restart',
|
||||
action='store_true',
|
||||
dest='restart',
|
||||
default=False,
|
||||
help='Restart dhcp3-server if serial is changed'),
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
output = options['output'] if options['output'] else sys.stdout
|
||||
force = options['force']
|
||||
restart = options['restart']
|
||||
restart_command = "service isc-dhcp-server restart"
|
||||
|
||||
config = DhcpConfig.objects.filter(name="default").get()
|
||||
|
||||
# do we need to do anything?
|
||||
if config.serial == config.active_serial and not force:
|
||||
print("Nothing to do, serial is the same: %s" % config.serial)
|
||||
sys.exit(0)
|
||||
|
||||
# write to stdout
|
||||
if output == sys.stdout:
|
||||
print(config.dhcpd_configuration())
|
||||
print('Not writing to file, serial not updated')
|
||||
return
|
||||
|
||||
# read current file contents
|
||||
try:
|
||||
with open(output, 'r') as f:
|
||||
old = f.read()
|
||||
except IOError:
|
||||
old = ''
|
||||
|
||||
# write to file
|
||||
new = config.dhcpd_configuration()
|
||||
with open(output, 'w') as f:
|
||||
f.write(new)
|
||||
|
||||
# calculate diff
|
||||
diff = "\n".join(context_diff(
|
||||
a=old.splitlines(),
|
||||
b=new.splitlines(),
|
||||
fromfile=config.active_serial,
|
||||
tofile=config.serial,
|
||||
lineterm=''))
|
||||
|
||||
print(diff)
|
||||
|
||||
# update serial
|
||||
old_serial = config.active_serial
|
||||
config.active_serial = config.serial
|
||||
config.save()
|
||||
|
||||
# restart the dhcp server?
|
||||
if restart:
|
||||
status, output = getstatusoutput(restart_command)
|
||||
if status != 0:
|
||||
message = (
|
||||
"Failed to restart dhcp3-server, please inspect...\n\n" +
|
||||
"%s@%s# %s" % (os.getlogin(), os.uname()[1], restart_command) +
|
||||
"\n%s\n\n%s" % (output, diff))
|
||||
mail_admins(
|
||||
subject="FAIL: dhcpd %s -> %s" % (old_serial, config.active_serial),
|
||||
message=message,
|
||||
html_message='<pre>%s</pre>' % message)
|
||||
sys.exit(1)
|
||||
else:
|
||||
message = (
|
||||
"Updated dhcpd configuration to serial %d\n\n" % config.active_serial +
|
||||
"%s" % diff)
|
||||
mail_admins(
|
||||
subject="Success: dhcpd %s -> %s" % (old_serial, config.active_serial),
|
||||
message=message,
|
||||
html_message='<pre>%s</pre>' % message)
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,83 @@
|
||||
import getpass
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from mdb.services import discord
|
||||
from mdb.services.dhcp_sync import DhcpSyncService
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
args = "--output <path> [--force] [--restart]"
|
||||
help = "Generates the DHCP configuration"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
action="store",
|
||||
dest="output",
|
||||
default=None,
|
||||
help="Path to dhcpd.conf, defaults to stdout",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
dest="force",
|
||||
default=False,
|
||||
help="Write configuration even if serial is unchanged.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restart",
|
||||
action="store_true",
|
||||
dest="restart",
|
||||
default=False,
|
||||
help="Restart isc-dhcp-server if serial is changed",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
output = options["output"]
|
||||
force = options["force"]
|
||||
restart = options["restart"]
|
||||
|
||||
service = DhcpSyncService(force=force)
|
||||
config = service.get_config()
|
||||
|
||||
if not service.needs_update(config):
|
||||
self.stdout.write(f"Nothing to do, serial is the same: {config.serial}\n")
|
||||
return
|
||||
|
||||
if output is None:
|
||||
self.stdout.write(config.dhcpd_configuration())
|
||||
self.stdout.write("Not writing to file, serial not updated\n")
|
||||
return
|
||||
|
||||
old_serial, diff = service.write_config(config, output)
|
||||
self.stdout.write(diff + "\n")
|
||||
|
||||
if restart:
|
||||
self.restart_and_notify(service, config, old_serial, diff)
|
||||
|
||||
def restart_and_notify(self, service, config, old_serial, diff):
|
||||
self.stdout.write("restarting isc-dhcp-server... ")
|
||||
success, output = service.restart_service()
|
||||
|
||||
if not success:
|
||||
self.stdout.write("fail\n")
|
||||
message = (
|
||||
"Failed to restart isc-dhcp-server, please inspect...\n\n"
|
||||
f"{getpass.getuser()}@{os.uname()[1]}$ {service.restart_command}\n{output}"
|
||||
)
|
||||
discord.notify(
|
||||
subject=f"⚠️ FAIL: dhcpd {old_serial} -> {config.active_serial}",
|
||||
message=message,
|
||||
preformatted=diff or None,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
self.stdout.write("ok\n")
|
||||
message = f"Updated dhcpd configuration to serial {config.active_serial:d}"
|
||||
discord.notify(
|
||||
subject=f"✅ Success: dhcpd {old_serial} -> {config.active_serial}",
|
||||
message=message,
|
||||
preformatted=diff or None,
|
||||
)
|
||||
@@ -1,175 +0,0 @@
|
||||
from __future__ import with_statement
|
||||
|
||||
import os
|
||||
import sys
|
||||
from itertools import chain
|
||||
from difflib import context_diff
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.mail import mail_admins
|
||||
from optparse import make_option
|
||||
try:
|
||||
from subprocess import getstatusoutput
|
||||
except ImportError:
|
||||
from commands import getstatusoutput
|
||||
|
||||
from mdb.models import Domain, Ip4Subnet, Ip6Subnet
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
args = '[--force] [--debug]'
|
||||
help = 'Generates the bind configuration'
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--debug',
|
||||
action='store_true',
|
||||
dest='debug',
|
||||
default=False,
|
||||
help='Debug mode. Writes to /tmp and does not reload.'),
|
||||
make_option('--force',
|
||||
action='store_true',
|
||||
dest='force',
|
||||
default=False,
|
||||
help='Write zone files even if serial is unchanged.'),
|
||||
)
|
||||
|
||||
bind_bin = "/etc/init.d/bind9"
|
||||
reload_command = "%s reload" % bind_bin
|
||||
checkzone_bin = "/usr/sbin/named-checkzone"
|
||||
checkzone_dir = "/tmp/mdb-checkzone"
|
||||
debug = False
|
||||
debug_dir = '/tmp/mdb-debug'
|
||||
changes = {}
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.debug = debug = options['debug']
|
||||
force = options['force']
|
||||
|
||||
# do the binaries exist?
|
||||
for f in (self.checkzone_bin, self.bind_bin):
|
||||
if not os.path.isfile(f):
|
||||
print("ERROR: no such file %s, exiting..." % f)
|
||||
if not debug:
|
||||
sys.exit(1)
|
||||
|
||||
# create directories if needed
|
||||
os.path.isdir(self.checkzone_dir) or os.mkdir(self.checkzone_dir)
|
||||
debug and (os.path.isdir(self.debug_dir) or os.mkdir(self.debug_dir))
|
||||
|
||||
# update zones with new serials
|
||||
for zone in chain(Domain.objects.all(),
|
||||
Ip4Subnet.objects.all(),
|
||||
Ip6Subnet.objects.all()):
|
||||
if zone.domain_serial == zone.domain_active_serial or force:
|
||||
continue
|
||||
if self.update_zone(zone):
|
||||
self.changes[zone.domain_name]['success'] = True
|
||||
|
||||
# join errors, diffs and zone names
|
||||
errors = "\n\n\n".join(
|
||||
[v['errors'] for k, v in self.changes.items() if v['errors']])
|
||||
diffs = "\n\n\n".join(
|
||||
[v['diff'] for k, v in self.changes.items() if v['diff']])
|
||||
zones = ", ".join(self.changes.keys())
|
||||
|
||||
# reload and send email with changes
|
||||
if self.changes and not debug:
|
||||
sys.stdout.write("reloading bind... ")
|
||||
status, output = getstatusoutput(self.reload_command)
|
||||
if status != 0:
|
||||
sys.stdout.write("fail\n")
|
||||
message = (
|
||||
"Failed to reload bind, please inspect...\n\n" +
|
||||
"%s@%s# %s" % (
|
||||
os.getlogin(), os.uname()[1], self.reload_command) +
|
||||
"\n%s\n\n\n%s\n\n\n%s" % (output, errors, diffs))
|
||||
mail_admins(
|
||||
subject="FAIL: bind (%s changed: %s)" % (len(self.changes), zones),
|
||||
message=message,
|
||||
html_message='<pre>%s</pre>' % message)
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.stdout.write("ok\n")
|
||||
message = (
|
||||
"Successfully updated bind zone files\n\n" +
|
||||
"%s\n\n%s" % (errors, diffs))
|
||||
mail_admins(
|
||||
subject="Success: bind (%s changed: %s)" % (len(self.changes), zones),
|
||||
message=message,
|
||||
html_message='<pre>%s</pre>' % message)
|
||||
sys.exit(0)
|
||||
|
||||
def update_zone(self, zone):
|
||||
zonetype = getattr(zone._meta, 'verbose_name', None) \
|
||||
or zone._meta.model_name
|
||||
|
||||
self.changes[zone.domain_name] = {
|
||||
'old_serial': zone.domain_active_serial,
|
||||
'new_serial': zone.domain_serial,
|
||||
'success': False,
|
||||
'errors': None,
|
||||
'diff': None,
|
||||
}
|
||||
|
||||
print("updating %s %s [%d -> %d]" % (
|
||||
zonetype,
|
||||
zone.domain_name,
|
||||
zone.domain_active_serial,
|
||||
zone.domain_serial))
|
||||
|
||||
# generate zone file contents
|
||||
new = zone.zone_file_contents()
|
||||
|
||||
# read current file contents
|
||||
try:
|
||||
with open(zone.domain_filename, 'r') as f:
|
||||
old = f.read()
|
||||
except IOError:
|
||||
old = ''
|
||||
|
||||
# calculate diff
|
||||
diff = "\n".join(context_diff(
|
||||
a=old.splitlines(),
|
||||
b=new.splitlines(),
|
||||
fromfile=str(zone.domain_active_serial) + '-' + zone.domain_name,
|
||||
tofile=str(zone.domain_serial) + '-' + zone.domain_name,
|
||||
lineterm=''))
|
||||
self.changes[zone.domain_name]['diff'] = diff
|
||||
|
||||
# validate with zonecheck
|
||||
sys.stdout.write("\t- validating... ")
|
||||
status, output = self.check_zone(zone, new)
|
||||
if status != 0:
|
||||
sys.stdout.write("fail\n")
|
||||
self.changes[zone.domain_name]['errors'] = output
|
||||
return False
|
||||
sys.stdout.write("ok\n")
|
||||
|
||||
# write to zone file
|
||||
sys.stdout.write("\t- writing zone file... ")
|
||||
|
||||
if not self.debug:
|
||||
zonefile = zone.domain_filename
|
||||
else:
|
||||
zonefile = self.debug_dir + zone.domain_filename
|
||||
path = "/".join(zonefile.split('/')[:-1])
|
||||
os.path.isdir(path) or os.makedirs(path)
|
||||
|
||||
with open(zonefile, 'w') as f:
|
||||
f.write(new)
|
||||
|
||||
# update serial
|
||||
if not self.debug:
|
||||
zone.domain_active_serial = zone.domain_serial
|
||||
zone.save()
|
||||
|
||||
sys.stdout.write("ok\n")
|
||||
self.changes[zone.domain_name]['success'] = True
|
||||
return True
|
||||
|
||||
def check_zone(self, zone, contents):
|
||||
zonefile = self.checkzone_dir + '/' + zone.domain_name
|
||||
with open(zonefile, 'w') as f:
|
||||
f.write(contents)
|
||||
|
||||
return getstatusoutput("%s %s %s" % (
|
||||
self.checkzone_bin, zone.domain_name, zonefile))
|
||||
@@ -0,0 +1,215 @@
|
||||
import getpass
|
||||
import os
|
||||
import signal
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from mdb.services import discord
|
||||
from mdb.services.zone_sync import ZoneSyncService
|
||||
|
||||
# What stops a sync run by hand: a dropped ssh session, a kill, Ctrl-C.
|
||||
STOPPING_SIGNALS = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT)
|
||||
|
||||
|
||||
class Interrupted(Exception):
|
||||
def __init__(self, signum):
|
||||
self.signum = signum
|
||||
super().__init__(signal.Signals(signum).name)
|
||||
|
||||
|
||||
def raise_interrupted(signum, frame):
|
||||
raise Interrupted(signum)
|
||||
|
||||
|
||||
def set_handlers(handler):
|
||||
for signum in STOPPING_SIGNALS:
|
||||
signal.signal(signum, handler)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
args = "[--force] [--debug]"
|
||||
help = "Generates the bind configuration"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
dest="debug",
|
||||
default=False,
|
||||
help="Debug mode. Writes to /tmp and does not reload.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
dest="force",
|
||||
default=False,
|
||||
help="Write zone files even if serial is unchanged.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--zone",
|
||||
dest="zone",
|
||||
default=None,
|
||||
metavar="ZONE",
|
||||
help="Sync a single zone by name instead of all zones.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
debug = options["debug"]
|
||||
force = options["force"]
|
||||
zone_name = options["zone"]
|
||||
|
||||
service = ZoneSyncService(debug=debug, force=force)
|
||||
|
||||
service.validate_binaries_exist()
|
||||
|
||||
os.path.isdir(service.checkzone_dir) or os.mkdir(service.checkzone_dir)
|
||||
debug and (os.path.isdir(service.debug_dir) or os.mkdir(service.debug_dir))
|
||||
|
||||
if not debug:
|
||||
self.thaw_after_killed_sync(service)
|
||||
|
||||
if zone_name:
|
||||
zone = service.find_zone(zone_name)
|
||||
if zone is None:
|
||||
self.stderr.write(f"error: zone '{zone_name}' not found\n")
|
||||
raise SystemExit(1)
|
||||
zones = [zone]
|
||||
else:
|
||||
zones = service.pending_zones()
|
||||
|
||||
if not zones:
|
||||
return
|
||||
|
||||
if debug:
|
||||
for zone in zones:
|
||||
service.sync_zone(zone)
|
||||
return
|
||||
|
||||
previous = {signum: signal.getsignal(signum) for signum in STOPPING_SIGNALS}
|
||||
try:
|
||||
# From the freeze to the thaw, a signal must not stop the sync in between:
|
||||
# ignored while rndc runs, it stops the writes for a reload and thaw.
|
||||
set_handlers(signal.SIG_IGN)
|
||||
self.freeze(service, zones)
|
||||
set_handlers(raise_interrupted)
|
||||
try:
|
||||
for zone in zones:
|
||||
service.sync_zone(zone)
|
||||
except BaseException as e:
|
||||
set_handlers(signal.SIG_IGN)
|
||||
self.reload_after_interruption(service, zones, e)
|
||||
if isinstance(e, Interrupted):
|
||||
raise SystemExit(128 + e.signum) from e
|
||||
raise
|
||||
set_handlers(signal.SIG_IGN)
|
||||
self.reload_and_notify(service, service.changes)
|
||||
finally:
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
|
||||
def thaw_after_killed_sync(self, service):
|
||||
since = service.frozen_since()
|
||||
if since is None:
|
||||
return
|
||||
|
||||
self.stdout.write("a previous sync left the zones frozen, thawing... ")
|
||||
summary = (
|
||||
f"A zone sync killed after {since:%Y-%m-%d %H:%M} left every zone frozen"
|
||||
)
|
||||
success, failed_command, output = service.reload()
|
||||
if not success:
|
||||
self.stdout.write("fail\n")
|
||||
self.notify_failure(
|
||||
f"{summary}, and thawing them failed", failed_command, output, []
|
||||
)
|
||||
self.stdout.write("ok\n")
|
||||
discord.notify(
|
||||
subject="⚠️ Zones thawed after a killed zone sync",
|
||||
message=f"{summary}, refusing dynamic updates until now. "
|
||||
"They are reloaded and thawed.",
|
||||
)
|
||||
|
||||
def reload_after_interruption(self, service, zones, error):
|
||||
reason = str(error) if isinstance(error, Interrupted) else repr(error)
|
||||
written = [z for z, c in service.changes.items() if c["success"]]
|
||||
not_written = [z.domain_name for z in zones if z.domain_name not in written]
|
||||
|
||||
self.stdout.write(f"stopped by {reason}, reloading zones... ")
|
||||
success, failed_command, output = service.reload()
|
||||
if not success:
|
||||
self.stdout.write("fail\n")
|
||||
self.notify_failure(
|
||||
f"Zone sync stopped by {reason}, and reloading the zones failed; "
|
||||
"they may still be frozen",
|
||||
failed_command,
|
||||
output,
|
||||
not_written,
|
||||
)
|
||||
self.stdout.write("ok\n")
|
||||
message = f"Zone sync stopped by {reason}. The zones are reloaded and thawed."
|
||||
if written:
|
||||
message += "\n\nWritten:\n" + "\n".join("- " + z for z in written)
|
||||
if not_written:
|
||||
message += "\n\nNot written, run mdb-sync again:\n" + "\n".join(
|
||||
"- " + z for z in not_written
|
||||
)
|
||||
discord.notify(subject="⚠️ Zone sync interrupted, zones thawed", message=message)
|
||||
|
||||
def freeze(self, service, zones):
|
||||
self.stdout.write("freezing zones... ")
|
||||
success, failed_command, output = service.freeze()
|
||||
if not success:
|
||||
self.stdout.write("fail\n")
|
||||
# A failed freeze may have frozen some zones before stopping.
|
||||
service.thaw()
|
||||
self.notify_failure(
|
||||
"Failed to freeze zones, nothing was written",
|
||||
failed_command,
|
||||
output,
|
||||
[z.domain_name for z in zones],
|
||||
)
|
||||
self.stdout.write("ok\n")
|
||||
|
||||
def reload_and_notify(self, service, changes):
|
||||
self.stdout.write("reloading zones... ")
|
||||
success, failed_command, output = service.reload()
|
||||
|
||||
if not success:
|
||||
self.stdout.write("fail\n")
|
||||
errors = "\n\n\n".join(
|
||||
[v["errors"] for v in changes.values() if v["errors"]]
|
||||
)
|
||||
diffs = "\n\n\n".join([v["diff"] for v in changes.values() if v["diff"]])
|
||||
self.notify_failure(
|
||||
"Failed to reload zones",
|
||||
failed_command,
|
||||
output,
|
||||
list(changes.keys()),
|
||||
errors,
|
||||
diffs,
|
||||
)
|
||||
|
||||
self.stdout.write("ok\n")
|
||||
for zone, info in changes.items():
|
||||
message = f"Successfully updated bind zone file for `{zone}`"
|
||||
if info["errors"]:
|
||||
message += "\n\n" + info["errors"]
|
||||
discord.notify(
|
||||
subject="✅ Success: bind",
|
||||
message=message,
|
||||
preformatted=info["diff"] or None,
|
||||
)
|
||||
|
||||
def notify_failure(
|
||||
self, summary, failed_command, output, zones, errors="", diffs=""
|
||||
):
|
||||
zone_list = "\n".join("- " + z for z in zones)
|
||||
message = f"{summary}, please inspect...\n\n{getpass.getuser()}@{os.uname()[1]}$ {failed_command}\n{output}\n{zone_list}"
|
||||
if errors:
|
||||
message += "\n\n" + errors
|
||||
discord.notify(
|
||||
subject="⚠️ FAIL: bind",
|
||||
message=message,
|
||||
preformatted=diffs or None,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import re
|
||||
import django.core.validators
|
||||
@@ -81,7 +79,7 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(max_length=256)),
|
||||
('target', models.CharField(max_length=256)),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('domain', models.ForeignKey(to='mdb.Domain')),
|
||||
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
},
|
||||
@@ -94,7 +92,7 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(max_length=256)),
|
||||
('target', models.CharField(max_length=256)),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('domain', models.ForeignKey(to='mdb.Domain')),
|
||||
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
},
|
||||
@@ -112,7 +110,7 @@ class Migration(migrations.Migration):
|
||||
('port', models.IntegerField()),
|
||||
('target', models.CharField(max_length=256)),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('domain', models.ForeignKey(to='mdb.Domain')),
|
||||
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
},
|
||||
@@ -125,7 +123,7 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(max_length=256)),
|
||||
('target', models.CharField(max_length=256)),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('domain', models.ForeignKey(to='mdb.Domain')),
|
||||
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
},
|
||||
@@ -174,8 +172,8 @@ class Migration(migrations.Migration):
|
||||
('pxe_filename', models.CharField(max_length=64, blank=True)),
|
||||
('dhcp_client', models.BooleanField(default=False)),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('domain', models.ForeignKey(to='mdb.Domain')),
|
||||
('host', models.ForeignKey(to='mdb.Host')),
|
||||
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
|
||||
('host', models.ForeignKey(to='mdb.Host', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
},
|
||||
@@ -217,7 +215,7 @@ class Migration(migrations.Migration):
|
||||
('dhcp_dynamic', models.BooleanField(default=False)),
|
||||
('dhcp_dynamic_start', models.IPAddressField(null=True, blank=True)),
|
||||
('dhcp_dynamic_end', models.IPAddressField(null=True, blank=True)),
|
||||
('dhcp_config', models.ForeignKey(to='mdb.DhcpConfig')),
|
||||
('dhcp_config', models.ForeignKey(to='mdb.DhcpConfig', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'IPv4 subnet',
|
||||
@@ -229,7 +227,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('address', models.CharField(max_length=64)),
|
||||
('interface', models.ForeignKey(to='mdb.Interface')),
|
||||
('interface', models.ForeignKey(to='mdb.Interface', on_delete=models.CASCADE)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'IPv6 address',
|
||||
@@ -309,7 +307,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='operatingsystem',
|
||||
name='architecture',
|
||||
field=models.ForeignKey(to='mdb.OsArchitecture'),
|
||||
field=models.ForeignKey(to='mdb.OsArchitecture', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
@@ -321,7 +319,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='ip6address',
|
||||
name='subnet',
|
||||
field=models.ForeignKey(to='mdb.Ip6Subnet'),
|
||||
field=models.ForeignKey(to='mdb.Ip6Subnet', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
@@ -333,25 +331,25 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='ip4address',
|
||||
name='subnet',
|
||||
field=models.ForeignKey(to='mdb.Ip4Subnet'),
|
||||
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='ip4address',
|
||||
field=models.ForeignKey(null=True, blank=True, to='mdb.Ip4Address', unique=True),
|
||||
field=models.ForeignKey(null=True, blank=True, to='mdb.Ip4Address', unique=True, on_delete=models.SET_NULL),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='host',
|
||||
name='host_type',
|
||||
field=models.ForeignKey(to='mdb.HostType'),
|
||||
field=models.ForeignKey(to='mdb.HostType', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='host',
|
||||
name='operating_system',
|
||||
field=models.ForeignKey(to='mdb.OperatingSystem'),
|
||||
field=models.ForeignKey(to='mdb.OperatingSystem', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
@@ -369,13 +367,13 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='dhcpoption',
|
||||
name='ip4subnet',
|
||||
field=models.ForeignKey(to='mdb.Ip4Subnet'),
|
||||
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='dhcpcustomfield',
|
||||
name='ip4subnet',
|
||||
field=models.ForeignKey(to='mdb.Ip4Subnet'),
|
||||
field=models.ForeignKey(to='mdb.Ip4Subnet', on_delete=models.CASCADE),
|
||||
preserve_default=True,
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import django.core.validators
|
||||
import re
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
from django.db.migrations import RunPython
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
import django.core.validators
|
||||
import re
|
||||
@@ -35,7 +33,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='ip4address',
|
||||
field=models.OneToOneField(to='mdb.Ip4Address', null=True, blank=True),
|
||||
field=models.OneToOneField(to='mdb.Ip4Address', null=True, blank=True, on_delete=models.SET_NULL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import re
|
||||
import django.core.validators
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -18,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(max_length=256)),
|
||||
('target', models.GenericIPAddressField(protocol='IPv6')),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('domain', models.ForeignKey(to='mdb.Domain')),
|
||||
('domain', models.ForeignKey(to='mdb.Domain', on_delete=models.CASCADE)),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mdb', '0010_auto_20151004_1709'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_initrd',
|
||||
field=models.CharField(null=True, blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_kernel',
|
||||
field=models.CharField(null=True, blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_preseed_config_url',
|
||||
field=models.URLField(null=True, blank=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.13 on 2025-05-12 03:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mdb', '0011_auto_20160325_1412'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='domaintxtrecord',
|
||||
name='target',
|
||||
field=models.CharField(max_length=2048),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-05-20 21:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mdb', '0012_alter_domaintxtrecord_target'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='host',
|
||||
name='allow_email',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
import ipaddress
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_classless_reverse_names(apps, schema_editor):
|
||||
"""Recompute domain_name for sub-/24 IPv4 subnets (RFC 2317 classless).
|
||||
|
||||
domain_name is frozen on first save, so subnets created before classless
|
||||
support kept the plain /24 name (190.36.158.in-addr.arpa) even for a /26.
|
||||
That apex no longer matches the classless zone declared in named.conf, so
|
||||
BIND refuses to load the file. Rewrite those names to the RFC 2317 form
|
||||
(192-26.190.36.158.in-addr.arpa). /24-or-shorter subnets are left alone.
|
||||
"""
|
||||
Ip4Subnet = apps.get_model("mdb", "Ip4Subnet")
|
||||
for subnet in Ip4Subnet.objects.all():
|
||||
try:
|
||||
net = ipaddress.IPv4Network(
|
||||
"%s/%s" % (subnet.network, subnet.netmask), strict=False
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
if net.prefixlen <= 24:
|
||||
continue
|
||||
o1, o2, o3, o4 = str(net.network_address).split(".")
|
||||
name = "%s-%d.%s.%s.%s.in-addr.arpa" % (o4, net.prefixlen, o3, o2, o1)
|
||||
if subnet.domain_name != name:
|
||||
subnet.domain_name = name
|
||||
subnet.save(update_fields=["domain_name"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("mdb", "0013_host_allow_email"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_classless_reverse_names, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 6.1.2 on 2026-11-04 20:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mdb', '0014_classless_reverse_zone_names'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_initrd',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_kernel',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='operatingsystem',
|
||||
name='pxe_preseed_config_url',
|
||||
),
|
||||
]
|
||||
+210
-236
@@ -1,16 +1,21 @@
|
||||
import datetime
|
||||
import ipaddress
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Count
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from mdb.utils import host_as_pxe_files
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from mdb.validators import validate_hostname, validate_macaddr
|
||||
|
||||
import ipaddress
|
||||
import datetime
|
||||
# The boot file of a PXE installable host. UEFI x86-64 clients, PXE architecture 7 or 9
|
||||
# in their vendor class, get the UEFI loader instead.
|
||||
PXE_FILENAME = "pxelinux.0"
|
||||
PXE_EFI_FILENAME = "grubx64.efi"
|
||||
PXE_EFI_CLIENT = " or ".join(
|
||||
f'substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:{arch}"'
|
||||
for arch in ("00007", "00009")
|
||||
)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Nameserver(models.Model):
|
||||
hostname = models.CharField(max_length=256)
|
||||
|
||||
@@ -18,7 +23,6 @@ class Nameserver(models.Model):
|
||||
return self.hostname
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class MailExchange(models.Model):
|
||||
priority = models.IntegerField()
|
||||
hostname = models.CharField(max_length=256)
|
||||
@@ -27,7 +31,6 @@ class MailExchange(models.Model):
|
||||
return "(" + str(self.priority) + ") " + self.hostname
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Domain(models.Model):
|
||||
domain_name = models.CharField(max_length=256)
|
||||
domain_soa = models.CharField(max_length=256)
|
||||
@@ -39,8 +42,10 @@ class Domain(models.Model):
|
||||
domain_expire = models.IntegerField(default=604800)
|
||||
domain_minimum_ttl = models.IntegerField(default=86400)
|
||||
domain_admin = models.EmailField()
|
||||
domain_ipaddr = models.GenericIPAddressField(protocol='IPv4')
|
||||
domain_ip6addr = models.GenericIPAddressField(protocol='IPv6', blank=True, null=True)
|
||||
domain_ipaddr = models.GenericIPAddressField(protocol="IPv4")
|
||||
domain_ip6addr = models.GenericIPAddressField(
|
||||
protocol="IPv6", blank=True, null=True
|
||||
)
|
||||
domain_filename = models.CharField(max_length=256)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -50,51 +55,40 @@ class Domain(models.Model):
|
||||
def __str__(self):
|
||||
return self.domain_name
|
||||
|
||||
def num_records(self):
|
||||
host_a_records = Host.objects.filter(interface__domain=self).count()
|
||||
size = {
|
||||
"CNAME": self.domaincnamerecord_set.count(),
|
||||
"SRV": self.domainsrvrecord_set.count(),
|
||||
"TXT": self.domaintxtrecord_set.count(),
|
||||
"A": self.domainarecord_set.count() + host_a_records
|
||||
}
|
||||
return ', '.join(['{}: {}'.format(k, v) for k, v in size.items()])
|
||||
|
||||
num_records.short_description = "Num Records"
|
||||
|
||||
def __eq__(self, other):
|
||||
if not other or not hasattr(other, 'domain_name'):
|
||||
if not other or not hasattr(other, "domain_name"):
|
||||
return False
|
||||
|
||||
return self.domain_name == other.domain_name
|
||||
|
||||
def zone_file_contents(self):
|
||||
content = "; serial:%d\n" % self.domain_serial
|
||||
content += "; zone file for %s\n" % self.domain_name
|
||||
content += "; %s\n" % datetime.datetime.now()
|
||||
content += "; filename: %s\n" % self.domain_filename
|
||||
content += "$TTL %s\n" % self.domain_ttl
|
||||
content += "@ IN SOA %s. %s. (\n" % (
|
||||
content = f"; serial:{self.domain_serial:d}\n"
|
||||
content += f"; zone file for {self.domain_name}\n"
|
||||
content += f"; {datetime.datetime.now()}\n"
|
||||
content += f"; filename: {self.domain_filename}\n"
|
||||
content += f"$TTL {self.domain_ttl}\n"
|
||||
content += "@ IN SOA {}. {}. (\n".format(
|
||||
self.domain_soa,
|
||||
self.domain_admin.replace("@", "."))
|
||||
content += "\t%d\t; serial\n" % self.domain_serial
|
||||
content += "\t%d\t; refresh\n" % self.domain_refresh
|
||||
content += "\t%d\t; retry\n" % self.domain_retry
|
||||
content += "\t%d\t; expire\n" % self.domain_expire
|
||||
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
|
||||
self.domain_admin.replace("@", "."),
|
||||
)
|
||||
content += f"\t{self.domain_serial:d}\t; serial\n"
|
||||
content += f"\t{self.domain_refresh:d}\t; refresh\n"
|
||||
content += f"\t{self.domain_retry:d}\t; retry\n"
|
||||
content += f"\t{self.domain_expire:d}\t; expire\n"
|
||||
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
|
||||
content += ";\n"
|
||||
|
||||
for nameserver in self.domain_nameservers.all():
|
||||
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
|
||||
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
|
||||
|
||||
for mx in self.domain_mailexchanges.all():
|
||||
content += "@\t\tMX\t%d %s.\n" % (mx.priority, mx.hostname)
|
||||
content += f"@\t\tMX\t{mx.priority:d} {mx.hostname}.\n"
|
||||
|
||||
if self.domain_ipaddr is not None:
|
||||
content += "@\tIN\tA\t%s\n" % self.domain_ipaddr
|
||||
content += f"@\tIN\tA\t{self.domain_ipaddr}\n"
|
||||
|
||||
if self.domain_ip6addr is not None:
|
||||
content += "@\tIN\tAAAA\t%s\n" % self.domain_ip6addr
|
||||
if self.domain_ip6addr:
|
||||
content += f"@\tIN\tAAAA\t{self.domain_ip6addr}\n"
|
||||
|
||||
content += "; SRV records\n"
|
||||
|
||||
@@ -114,7 +108,7 @@ class Domain(models.Model):
|
||||
content += "; CNAME records \n"
|
||||
|
||||
for cname in self.domaincnamerecord_set.all():
|
||||
content += "%s\tIN\tCNAME\t%s\n" % (cname.name, cname.target)
|
||||
content += f"{cname.name}\tIN\tCNAME\t{cname.target}\n"
|
||||
|
||||
content += "; TXT records \n"
|
||||
|
||||
@@ -126,18 +120,21 @@ class Domain(models.Model):
|
||||
for interface in self.interface_set.all():
|
||||
host = interface.host
|
||||
if interface.ip4address:
|
||||
content += "%-20s\tIN\tA\t%s\n" % (
|
||||
host.hostname,
|
||||
interface.ip4address.address)
|
||||
content += (
|
||||
f"{host.hostname:<20}\tIN\tA\t{interface.ip4address.address}\n"
|
||||
)
|
||||
|
||||
for ipv6addr in interface.ip6address_set.all():
|
||||
content += "%-20s\tIN\tAAAA\t%s\n" % (
|
||||
host.hostname,
|
||||
ipv6addr.full_address())
|
||||
content += f"{host.hostname:<20}\tIN\tAAAA\t{ipv6addr.full_address()}\n"
|
||||
|
||||
if host.allow_email and interface.ip4address:
|
||||
content += f'{host.hostname}\tTXT\t"v=spf1 mx ip4:{interface.ip4address.address} -all"\n'
|
||||
elif host.allow_email:
|
||||
content += f'{host.hostname}\tTXT\t"v=spf1 mx -all"\n'
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DomainSrvRecord(models.Model):
|
||||
srvce = models.CharField(max_length=64)
|
||||
prot = models.CharField(max_length=64)
|
||||
@@ -146,82 +143,71 @@ class DomainSrvRecord(models.Model):
|
||||
weight = models.IntegerField()
|
||||
port = models.IntegerField()
|
||||
target = models.CharField(max_length=256)
|
||||
domain = models.ForeignKey(Domain)
|
||||
domain = models.ForeignKey(Domain, models.CASCADE)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def as_record(self):
|
||||
return '{}.{}.{}. IN SRV {} {} {} {}.'.format(
|
||||
self.srvce,
|
||||
self.prot,
|
||||
self.name,
|
||||
|
||||
self.priority,
|
||||
self.weight,
|
||||
self.port,
|
||||
self.target
|
||||
)
|
||||
return f"{self.srvce}.{self.prot}.{self.name}. IN SRV {self.priority} {self.weight} {self.port} {self.target}."
|
||||
|
||||
def __str__(self):
|
||||
return self.as_record()
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DomainTxtRecord(models.Model):
|
||||
name = models.CharField(max_length=256)
|
||||
target = models.CharField(max_length=256)
|
||||
domain = models.ForeignKey(Domain)
|
||||
# note that the max length of a TXT record is 255, but
|
||||
# bind allows for longer records by splitting them
|
||||
# into chunks as: "...255..." "...another 255..."
|
||||
target = models.CharField(max_length=2048)
|
||||
domain = models.ForeignKey(Domain, models.CASCADE)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def as_record(self):
|
||||
return '{} TXT {}'.format(self.name, self.target)
|
||||
return f"{self.name} TXT {self.target}"
|
||||
|
||||
def __str__(self):
|
||||
return self.as_record()
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DomainCnameRecord(models.Model):
|
||||
name = models.CharField(max_length=256)
|
||||
target = models.CharField(max_length=256)
|
||||
domain = models.ForeignKey(Domain)
|
||||
domain = models.ForeignKey(Domain, models.CASCADE)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def as_record(self):
|
||||
return '{} IN CNAME {}'.format(self.name, self.target)
|
||||
return f"{self.name} IN CNAME {self.target}"
|
||||
|
||||
def __str__(self):
|
||||
return self.as_record()
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DomainARecord(models.Model):
|
||||
name = models.CharField(max_length=256)
|
||||
target = models.GenericIPAddressField(protocol='IPv4')
|
||||
domain = models.ForeignKey(Domain)
|
||||
target = models.GenericIPAddressField(protocol="IPv4")
|
||||
domain = models.ForeignKey(Domain, models.CASCADE)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def as_record(self):
|
||||
return '{} IN A {}'.format(self.name, self.target)
|
||||
return f"{self.name} IN A {self.target}"
|
||||
|
||||
def __str__(self):
|
||||
return self.as_record()
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DomainAAAARecord(models.Model):
|
||||
name = models.CharField(max_length=256)
|
||||
target = models.GenericIPAddressField(protocol='IPv6')
|
||||
domain = models.ForeignKey(Domain)
|
||||
target = models.GenericIPAddressField(protocol="IPv6")
|
||||
domain = models.ForeignKey(Domain, models.CASCADE)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def as_record(self):
|
||||
return '{} IN AAAA {}'.format(self.name, self.target)
|
||||
return f"{self.name} IN AAAA {self.target}"
|
||||
|
||||
def __str__(self):
|
||||
return self.as_record()
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DhcpConfig(models.Model):
|
||||
serial = models.IntegerField()
|
||||
active_serial = models.IntegerField()
|
||||
@@ -233,45 +219,56 @@ class DhcpConfig(models.Model):
|
||||
log_facility = models.CharField(max_length=255)
|
||||
|
||||
def dhcpd_configuration(self):
|
||||
content = "# Autogenerated configuration %s\n" % datetime.datetime.now()
|
||||
content = f"# Autogenerated configuration {datetime.datetime.now()}\n"
|
||||
if self.authoritative:
|
||||
content += "authoritative;\n"
|
||||
content += "default-lease-time %d;\n" % self.default_lease_time
|
||||
content += "max-lease-time %d;\n" % self.max_lease_time
|
||||
content += "log-facility %s;\n" % self.log_facility
|
||||
content += "ddns-update-style %s;\n" % self.ddns_update_style
|
||||
content += f"default-lease-time {self.default_lease_time:d};\n"
|
||||
content += f"max-lease-time {self.max_lease_time:d};\n"
|
||||
content += f"log-facility {self.log_facility};\n"
|
||||
content += f"ddns-update-style {self.ddns_update_style};\n"
|
||||
|
||||
# time to write the subnet definitions
|
||||
for subnet in self.ip4subnet_set.all():
|
||||
content += "\n# %s\n" % subnet.name
|
||||
content += "subnet %s netmask %s {\n" % (
|
||||
subnet.network,
|
||||
subnet.netmask)
|
||||
content += f"\n# {subnet.name}\n"
|
||||
content += f"subnet {subnet.network} netmask {subnet.netmask} {{\n"
|
||||
|
||||
for option in subnet.dhcpoption_set.all():
|
||||
content += "\toption %s %s;\n" % (option.key, option.value)
|
||||
content += f"\toption {option.key} {option.value};\n"
|
||||
|
||||
for option in subnet.dhcpcustomfield_set.all():
|
||||
content += "\t%s;\n" % option.value
|
||||
content += f"\t{option.value};\n"
|
||||
|
||||
if subnet.dhcp_dynamic:
|
||||
content += "\trange %s %s;\n" \
|
||||
% (subnet.dhcp_dynamic_start, subnet.dhcp_dynamic_end)
|
||||
content += (
|
||||
f"\trange {subnet.dhcp_dynamic_start} {subnet.dhcp_dynamic_end};\n"
|
||||
)
|
||||
|
||||
content += "}\n"
|
||||
|
||||
# time to write host definitions
|
||||
for subnet in self.ip4subnet_set.all():
|
||||
for ip4address in subnet.ip4address_set.all():
|
||||
if not hasattr(ip4address, 'interface') or not ip4address.interface.dhcp_client:
|
||||
if (
|
||||
not hasattr(ip4address, "interface")
|
||||
or not ip4address.interface.dhcp_client
|
||||
):
|
||||
continue
|
||||
_if = ip4address.interface
|
||||
content += "\nhost %s {\n" % _if.host.hostname
|
||||
content += "\thardware ethernet %s;\n" % _if.macaddr
|
||||
content += "\tfixed-address %s.%s;\n" % \
|
||||
(_if.host.hostname, _if.domain.domain_name)
|
||||
if len(_if.pxe_filename) > 0:
|
||||
content += "\tfilename \"%s\";\n" % _if.pxe_filename
|
||||
content += f"\nhost {_if.host.hostname} {{\n"
|
||||
content += f"\thardware ethernet {_if.macaddr};\n"
|
||||
content += (
|
||||
f"\tfixed-address {_if.host.hostname}.{_if.domain.domain_name};\n"
|
||||
)
|
||||
if _if.pxe_filename == PXE_FILENAME:
|
||||
content += (
|
||||
f"\tif {PXE_EFI_CLIENT} {{\n"
|
||||
f'\t\tfilename "{PXE_EFI_FILENAME}";\n'
|
||||
"\t} else {\n"
|
||||
f'\t\tfilename "{PXE_FILENAME}";\n'
|
||||
"\t}\n"
|
||||
)
|
||||
elif _if.pxe_filename:
|
||||
content += f'\tfilename "{_if.pxe_filename}";\n'
|
||||
content += "}\n"
|
||||
|
||||
return content
|
||||
@@ -280,10 +277,9 @@ class DhcpConfig(models.Model):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'DHCP config'
|
||||
verbose_name = "DHCP config"
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Ip6Subnet(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
network = models.CharField(max_length=255)
|
||||
@@ -305,25 +301,38 @@ class Ip6Subnet(models.Model):
|
||||
def __str__(self):
|
||||
return self.network + " (" + self.name + ")"
|
||||
|
||||
@property
|
||||
def reverse_zone_name(self):
|
||||
"""The ip6.arpa reverse zone name for this subnet's prefix.
|
||||
|
||||
One label per nibble, derived from ``netmask`` (the prefix length).
|
||||
Delegation in ip6.arpa happens on nibble (4-bit) boundaries, so the
|
||||
prefix is truncated down to whole nibbles.
|
||||
"""
|
||||
network = ipaddress.IPv6Address(f"{self.network}::")
|
||||
nibbles = self.netmask // 4
|
||||
return ".".join(network.exploded.replace(":", "")[:nibbles])[::-1] + ".ip6.arpa"
|
||||
|
||||
def zone_file_contents(self, generate_unassigned=False):
|
||||
content = ""
|
||||
content += "; zone file for %s\n" % self.domain_name
|
||||
content += "; %s\n" % datetime.datetime.now()
|
||||
content += "; filename: %s\n" % self.domain_filename
|
||||
content += "$TTL %s\n" % self.domain_ttl
|
||||
content += "@ IN SOA %s. %s. (\n" % (
|
||||
content += f"; zone file for {self.domain_name}\n"
|
||||
content += f"; {datetime.datetime.now()}\n"
|
||||
content += f"; filename: {self.domain_filename}\n"
|
||||
content += f"$TTL {self.domain_ttl}\n"
|
||||
content += "@ IN SOA {}. {}. (\n".format(
|
||||
self.domain_soa,
|
||||
self.domain_admin.replace("@", "."))
|
||||
content += "\t%d\t; serial\n" % self.domain_serial
|
||||
content += "\t%d\t; refresh\n" % self.domain_refresh
|
||||
content += "\t%d\t; retry\n" % self.domain_retry
|
||||
content += "\t%d\t; expire\n" % self.domain_expire
|
||||
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
|
||||
self.domain_admin.replace("@", "."),
|
||||
)
|
||||
content += f"\t{self.domain_serial:d}\t; serial\n"
|
||||
content += f"\t{self.domain_refresh:d}\t; refresh\n"
|
||||
content += f"\t{self.domain_retry:d}\t; retry\n"
|
||||
content += f"\t{self.domain_expire:d}\t; expire\n"
|
||||
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
|
||||
content += ";\n"
|
||||
content += ";\n"
|
||||
|
||||
for nameserver in self.domain_nameservers.all():
|
||||
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
|
||||
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
|
||||
|
||||
content += ";\n"
|
||||
|
||||
@@ -332,17 +341,17 @@ class Ip6Subnet(models.Model):
|
||||
# content += "$ORIGIN " + ".".join(network.exploded.replace(":","")[:16])[::-1] + ".ip6.arpa.\n"
|
||||
|
||||
for addr in self.ip6address_set.all():
|
||||
#if addr.interface_set.count() == 0: continue
|
||||
# if addr.interface_set.count() == 0: continue
|
||||
|
||||
if addr.interface.domain is None:
|
||||
continue
|
||||
hostname = "%s.%s" % (
|
||||
addr.interface.host.hostname,
|
||||
addr.interface.domain.domain_name)
|
||||
hostname = (
|
||||
f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
|
||||
)
|
||||
|
||||
ip = ipaddress.IPv6Address(self.network + addr.address)
|
||||
ip = ".".join(ip.exploded.replace(":", "")[16:])[::-1]
|
||||
content += "%s\tPTR\t%s.\n" % (ip, hostname)
|
||||
content += f"{ip}\tPTR\t{hostname}.\n"
|
||||
|
||||
# addr = ipaddr.IPv6Address("%s%s" % (interface.
|
||||
# content += "%20s\tIN\tPTR\t%s.\n" % (addr.address.split(".")[3], hostname)
|
||||
@@ -350,14 +359,13 @@ class Ip6Subnet(models.Model):
|
||||
return content
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'IPv6 subnet'
|
||||
verbose_name = "IPv6 subnet"
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Ip4Subnet(models.Model):
|
||||
name = models.CharField(max_length=256)
|
||||
netmask = models.GenericIPAddressField(protocol='IPv4')
|
||||
network = models.GenericIPAddressField(protocol='IPv4')
|
||||
netmask = models.GenericIPAddressField(protocol="IPv4")
|
||||
network = models.GenericIPAddressField(protocol="IPv4")
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
domain_name = models.CharField(max_length=255, editable=False)
|
||||
domain_nameservers = models.ManyToManyField(Nameserver)
|
||||
@@ -373,122 +381,109 @@ class Ip4Subnet(models.Model):
|
||||
domain_filename = models.CharField(max_length=256)
|
||||
|
||||
dhcp_dynamic = models.BooleanField(default=False)
|
||||
dhcp_dynamic_start = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
|
||||
dhcp_dynamic_end = models.GenericIPAddressField(protocol='IPv4', null=True, blank=True)
|
||||
dhcp_config = models.ForeignKey(DhcpConfig)
|
||||
dhcp_dynamic_start = models.GenericIPAddressField(
|
||||
protocol="IPv4", null=True, blank=True
|
||||
)
|
||||
dhcp_dynamic_end = models.GenericIPAddressField(
|
||||
protocol="IPv4", null=True, blank=True
|
||||
)
|
||||
dhcp_config = models.ForeignKey(DhcpConfig, models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.network + " (" + self.name + ")"
|
||||
|
||||
def num_addresses(self):
|
||||
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
|
||||
return subnet.num_addresses
|
||||
@property
|
||||
def reverse_zone_name(self):
|
||||
"""The in-addr.arpa reverse zone name for this subnet.
|
||||
|
||||
def broadcast_address(self):
|
||||
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
|
||||
return subnet.broadcast_address
|
||||
|
||||
def first_address(self):
|
||||
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
|
||||
return next(subnet.hosts())
|
||||
|
||||
def last_address(self):
|
||||
subnet = ipaddress.IPv4Network(self.network + "/" + self.netmask)
|
||||
return list(subnet.hosts())[-1]
|
||||
|
||||
broadcast_address.short_description = 'broadcast'
|
||||
num_addresses.short_description = '#addresses'
|
||||
first_address.short_description = 'first address'
|
||||
last_address.short_description = 'last address'
|
||||
Masks of /24 or shorter give the standard octet-boundary name
|
||||
(190.36.158.in-addr.arpa). Masks longer than /24 give the RFC 2317
|
||||
classless name for the sub-/24 block (192-26.190.36.158.in-addr.arpa).
|
||||
This is the apex BIND expects -- it must match the zone name in
|
||||
named.conf or rndc refuses to load the file.
|
||||
"""
|
||||
net = ipaddress.IPv4Network(f"{self.network}/{self.netmask}", strict=False)
|
||||
o1, o2, o3, o4 = str(net.network_address).split(".")
|
||||
rev = f"{o3}.{o2}.{o1}"
|
||||
if net.prefixlen > 24:
|
||||
return f"{o4}-{net.prefixlen:d}.{rev}.in-addr.arpa"
|
||||
return f"{rev}.in-addr.arpa"
|
||||
|
||||
def zone_file_contents(self, generate_unassigned=False):
|
||||
content = ""
|
||||
content += "; zone file for %s\n" % self.domain_name
|
||||
content += "; %s\n" % datetime.datetime.now()
|
||||
content += "; filename: %s\n" % self.domain_filename
|
||||
content += "$TTL %s\n" % self.domain_ttl
|
||||
content += "@ IN SOA %s. %s. (\n" % (
|
||||
content += f"; zone file for {self.domain_name}\n"
|
||||
content += f"; {datetime.datetime.now()}\n"
|
||||
content += f"; filename: {self.domain_filename}\n"
|
||||
content += f"$TTL {self.domain_ttl}\n"
|
||||
content += "@ IN SOA {}. {}. (\n".format(
|
||||
self.domain_soa,
|
||||
self.domain_admin.replace("@", "."))
|
||||
content += "\t%d\t; serial\n" % self.domain_serial
|
||||
content += "\t%d\t; refresh\n" % self.domain_refresh
|
||||
content += "\t%d\t; retry\n" % self.domain_retry
|
||||
content += "\t%d\t; expire\n" % self.domain_expire
|
||||
content += "\t%d )\t; minimum ttl\n" % self.domain_minimum_ttl
|
||||
self.domain_admin.replace("@", "."),
|
||||
)
|
||||
content += f"\t{self.domain_serial:d}\t; serial\n"
|
||||
content += f"\t{self.domain_refresh:d}\t; refresh\n"
|
||||
content += f"\t{self.domain_retry:d}\t; retry\n"
|
||||
content += f"\t{self.domain_expire:d}\t; expire\n"
|
||||
content += f"\t{self.domain_minimum_ttl:d} )\t; minimum ttl\n"
|
||||
content += ";\n"
|
||||
content += ";\n"
|
||||
|
||||
for nameserver in self.domain_nameservers.all():
|
||||
content += "@\tIN\tNS\t%s.\n" % nameserver.hostname
|
||||
content += f"@\tIN\tNS\t{nameserver.hostname}.\n"
|
||||
|
||||
content += ";\n"
|
||||
|
||||
for addr in self.ip4address_set.all():
|
||||
""" Get PTR records for each v4 address in subnet """
|
||||
if hasattr(addr, 'interface') and addr.interface.domain:
|
||||
hostname = "%s.%s" % (
|
||||
addr.interface.host.hostname,
|
||||
addr.interface.domain.domain_name)
|
||||
content += "%-20s\tIN\tPTR\t%s.\n" % \
|
||||
(addr.address.split(".")[3], hostname)
|
||||
elif not hasattr(addr, 'interface') and generate_unassigned:
|
||||
content += "%s\tIN\tPTR\t%s.%s\n" % (
|
||||
if hasattr(addr, "interface") and addr.interface.domain:
|
||||
hostname = f"{addr.interface.host.hostname}.{addr.interface.domain.domain_name}"
|
||||
content += f"{addr.address.split('.')[3]:<20}\tIN\tPTR\t{hostname}.\n"
|
||||
elif not hasattr(addr, "interface") and generate_unassigned:
|
||||
content += "{}\tIN\tPTR\t{}.{}\n".format(
|
||||
addr.address,
|
||||
addr.address.split(".")[3],
|
||||
"dhcp.neuf.no.")
|
||||
"dhcp.neuf.no.",
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'IPv4 subnet'
|
||||
verbose_name = "IPv4 subnet"
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class DhcpOption(models.Model):
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.CharField(max_length=255)
|
||||
ip4subnet = models.ForeignKey(Ip4Subnet)
|
||||
ip4subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.key + " " + self.value
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'DHCP option'
|
||||
verbose_name = "DHCP option"
|
||||
|
||||
|
||||
class DhcpCustomField(models.Model):
|
||||
value = models.CharField(max_length=255)
|
||||
ip4subnet = models.ForeignKey(Ip4Subnet)
|
||||
ip4subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'DHCP custom field'
|
||||
verbose_name = "DHCP custom field"
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Ip4Address(models.Model):
|
||||
subnet = models.ForeignKey(Ip4Subnet)
|
||||
address = models.GenericIPAddressField(protocol='IPv4')
|
||||
subnet = models.ForeignKey(Ip4Subnet, models.CASCADE)
|
||||
address = models.GenericIPAddressField(protocol="IPv4")
|
||||
last_contact = models.DateTimeField(null=True, blank=True)
|
||||
ping_avg_rtt = models.FloatField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
# FIXME: generates 2 SQL queries?
|
||||
if not hasattr(self, 'interface'):
|
||||
return self.address
|
||||
|
||||
return "{} ({})".format(self.address, self.interface.host.hostname)
|
||||
|
||||
def assigned_to_host(self):
|
||||
return self.interface.host
|
||||
|
||||
assigned_to_host.short_description = "Assigned to Host"
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'IPv4 address'
|
||||
verbose_name_plural = 'IPv4 addresses'
|
||||
verbose_name = "IPv4 address"
|
||||
verbose_name_plural = "IPv4 addresses"
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class HostType(models.Model):
|
||||
host_type = models.CharField(max_length=64)
|
||||
description = models.CharField(max_length=1024)
|
||||
@@ -503,22 +498,23 @@ class HostType(models.Model):
|
||||
ordering = ("host_type",)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class OperatingSystem(models.Model):
|
||||
OS_ARCHITECTURES = (
|
||||
('mips', _('MIPS')),
|
||||
('arm', _('ARM')),
|
||||
('broadcom', _('Broadcom')),
|
||||
('rc32300', _('RC32300')),
|
||||
('powerpc', _('PowerPC')),
|
||||
('powerpc403ga', _('PowerPC403GA')),
|
||||
('unknown', _('Unknown')),
|
||||
('x86_64', _('x86-64')),
|
||||
('i386', _('x86')),
|
||||
("mips", _("MIPS")),
|
||||
("arm", _("ARM")),
|
||||
("broadcom", _("Broadcom")),
|
||||
("rc32300", _("RC32300")),
|
||||
("powerpc", _("PowerPC")),
|
||||
("powerpc403ga", _("PowerPC403GA")),
|
||||
("unknown", _("Unknown")),
|
||||
("x86_64", _("x86-64")),
|
||||
("i386", _("x86")),
|
||||
)
|
||||
name = models.CharField(max_length=256)
|
||||
version = models.CharField(max_length=64)
|
||||
arch = models.CharField(max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True)
|
||||
arch = models.CharField(
|
||||
max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name + " " + self.version + " (" + self.arch + ")"
|
||||
@@ -527,7 +523,6 @@ class OperatingSystem(models.Model):
|
||||
ordering = ("name", "version")
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Host(models.Model):
|
||||
location = models.CharField(max_length=1024)
|
||||
brand = models.CharField(max_length=1024)
|
||||
@@ -537,14 +532,19 @@ class Host(models.Model):
|
||||
serial_number = models.CharField(max_length=256)
|
||||
description = models.CharField(max_length=1024)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
host_type = models.ForeignKey(HostType)
|
||||
host_type = models.ForeignKey(HostType, models.CASCADE) # FIXME: SET_NULL?
|
||||
virtual = models.BooleanField(default=False)
|
||||
operating_system = models.ForeignKey(OperatingSystem)
|
||||
allow_email = models.BooleanField(default=False)
|
||||
operating_system = models.ForeignKey(
|
||||
OperatingSystem, models.CASCADE
|
||||
) # FIXME: SET_NULL?
|
||||
|
||||
request_kerberos_principal = models.BooleanField(default=False)
|
||||
kerberos_principal_created = models.BooleanField(default=False, editable=False)
|
||||
kerberos_principal_name = models.CharField(max_length=256, editable=False)
|
||||
kerberos_principal_created_date = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
kerberos_principal_created_date = models.DateTimeField(
|
||||
null=True, blank=True, editable=False
|
||||
)
|
||||
|
||||
pxe_key = models.CharField(max_length=254, blank=True)
|
||||
pxe_installable = models.BooleanField(default=False)
|
||||
@@ -552,63 +552,37 @@ class Host(models.Model):
|
||||
def __str__(self):
|
||||
return self.hostname
|
||||
|
||||
def in_domain(self):
|
||||
domains = self.interface_set.values_list('domain__domain_name', flat=True)
|
||||
return ",".join(domains)
|
||||
|
||||
in_domain.short_description = "in domains"
|
||||
|
||||
def ipv6_enabled(self):
|
||||
ipv6_ifs = self.interface_set.annotate(num_ipv6=Count('ip6address')).filter(num_ipv6__gt=0)
|
||||
return ipv6_ifs.exists()
|
||||
|
||||
ipv6_enabled.boolean = True
|
||||
|
||||
def mac_addresses(self):
|
||||
addresses = self.interface_set.filter(macaddr__isnull=False).values_list('macaddr', flat=True)
|
||||
return ", ".join(addresses)
|
||||
|
||||
def ip_addresses(self):
|
||||
addresses = self.interface_set.filter(ip4address__isnull=False).values_list('ip4address__address', flat=True)
|
||||
return ", ".join(addresses)
|
||||
|
||||
def as_pxe_files(self):
|
||||
return host_as_pxe_files(self)
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Interface(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
macaddr = models.CharField(max_length=17, validators=[validate_macaddr])
|
||||
pxe_filename = models.CharField(max_length=64, blank=True)
|
||||
dhcp_client = models.BooleanField(default=False)
|
||||
host = models.ForeignKey(Host)
|
||||
ip4address = models.OneToOneField(Ip4Address, blank=True, null=True)
|
||||
host = models.ForeignKey(Host, models.CASCADE)
|
||||
ip4address = models.OneToOneField(
|
||||
Ip4Address, blank=True, null=True, on_delete=models.SET_NULL
|
||||
)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
domain = models.ForeignKey(Domain)
|
||||
domain = models.ForeignKey(Domain, models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return "%s (%s on %s)" % (self.macaddr, self.name, self.host.hostname)
|
||||
return f"{self.macaddr} ({self.name} on {self.host.hostname})"
|
||||
|
||||
def ipv6_enabled(self):
|
||||
return self.ip6address_set.exists()
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Ip6Address(models.Model):
|
||||
subnet = models.ForeignKey(Ip6Subnet)
|
||||
address = models.GenericIPAddressField(protocol='IPv6')
|
||||
interface = models.ForeignKey(Interface)
|
||||
subnet = models.ForeignKey(Ip6Subnet, models.CASCADE)
|
||||
address = models.GenericIPAddressField(protocol="IPv6")
|
||||
interface = models.ForeignKey(Interface, models.CASCADE)
|
||||
|
||||
def full_address(self):
|
||||
return self.subnet.network + self.address
|
||||
|
||||
def __str__(self):
|
||||
return "%s (%s on %s)" % (
|
||||
self.full_address(),
|
||||
self.interface.name,
|
||||
self.interface.host.hostname)
|
||||
return f"{self.full_address()} ({self.interface.name} on {self.interface.host.hostname})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'IPv6 address'
|
||||
verbose_name_plural = 'IPv6 addresses'
|
||||
verbose_name = "IPv6 address"
|
||||
verbose_name_plural = "IPv6 addresses"
|
||||
Whitespace-only changes.
@@ -0,0 +1,59 @@
|
||||
from difflib import context_diff
|
||||
from subprocess import getstatusoutput
|
||||
|
||||
from mdb.models import DhcpConfig
|
||||
|
||||
|
||||
class DhcpSyncService:
|
||||
restart_command = "/usr/sbin/service isc-dhcp-server restart"
|
||||
|
||||
def __init__(self, force=False):
|
||||
self.force = force
|
||||
|
||||
def get_config(self):
|
||||
return DhcpConfig.objects.filter(name="default").get()
|
||||
|
||||
def needs_update(self, config):
|
||||
return config.serial != config.active_serial or self.force
|
||||
|
||||
def calculate_diff(self, config, new, old):
|
||||
return "\n".join(
|
||||
context_diff(
|
||||
a=old.splitlines(),
|
||||
b=new.splitlines(),
|
||||
fromfile=str(config.active_serial),
|
||||
tofile=str(config.serial),
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
|
||||
def write_config(self, config, output_path):
|
||||
"""Write new DHCP config to file and update active_serial.
|
||||
|
||||
Returns (old_serial, diff).
|
||||
"""
|
||||
try:
|
||||
with open(output_path) as f:
|
||||
old = f.read()
|
||||
except OSError:
|
||||
old = ""
|
||||
|
||||
new = config.dhcpd_configuration()
|
||||
with open(output_path, "w") as f:
|
||||
f.write(new)
|
||||
|
||||
diff = self.calculate_diff(config, new, old)
|
||||
|
||||
old_serial = config.active_serial
|
||||
config.active_serial = config.serial
|
||||
config.save()
|
||||
|
||||
return old_serial, diff
|
||||
|
||||
def restart_service(self):
|
||||
"""Run the DHCP server restart command.
|
||||
|
||||
Returns (success, output).
|
||||
"""
|
||||
status, output = getstatusoutput(self.restart_command)
|
||||
return status == 0, output
|
||||
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
MAX_CONTENT_LENGTH = 2000
|
||||
# Seconds; a hung webhook must not hold up the sync that called it.
|
||||
TIMEOUT = 10
|
||||
|
||||
|
||||
def notify(subject, message, preformatted=None):
|
||||
"""Send a notification to the configured Discord webhook.
|
||||
|
||||
Args:
|
||||
subject: Bold title line.
|
||||
message: Plain text body.
|
||||
preformatted: Optional content shown as a diff code block.
|
||||
"""
|
||||
webhook_url = getattr(settings, "DISCORD_NOTIFICATION_WEBHOOK", None)
|
||||
if not webhook_url:
|
||||
return
|
||||
|
||||
content = f"**{subject}**"
|
||||
if message:
|
||||
content += f"\n{message}"
|
||||
if preformatted:
|
||||
content += f"\n```diff\n{preformatted}\n```"
|
||||
|
||||
if len(content) <= MAX_CONTENT_LENGTH:
|
||||
_send_json(webhook_url, content)
|
||||
else:
|
||||
_send_as_file(webhook_url, subject, content)
|
||||
|
||||
|
||||
def _send_json(url, content):
|
||||
resp = requests.post(
|
||||
url, json={"username": "MDB", "content": content}, timeout=TIMEOUT
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _send_as_file(url, subject, content):
|
||||
payload = {"username": "MDB", "content": f"**{subject}** *(see attachment)*"}
|
||||
resp = requests.post(
|
||||
url,
|
||||
data={"payload_json": json.dumps(payload)},
|
||||
files={"files[0]": ("notification.txt", content.encode(), "text/plain")},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -0,0 +1,247 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from difflib import context_diff
|
||||
from itertools import chain
|
||||
from subprocess import getstatusoutput
|
||||
|
||||
from mdb.models import Domain, Ip4Subnet, Ip6Subnet
|
||||
|
||||
|
||||
class ZoneSyncService:
|
||||
rndc_bin = shutil.which("rndc") or "/usr/sbin/rndc"
|
||||
freeze_command = f"{rndc_bin} freeze"
|
||||
reload_command = f"{rndc_bin} reload"
|
||||
thaw_command = f"{rndc_bin} thaw"
|
||||
checkzone_bin = shutil.which("named-checkzone") or "/usr/sbin/named-checkzone"
|
||||
compilezone_bin = shutil.which("named-compilezone") or "/usr/sbin/named-compilezone"
|
||||
checkzone_dir = "/tmp/mdb-checkzone"
|
||||
debug_dir = "/tmp/mdb-debug"
|
||||
|
||||
def __init__(self, debug=False, force=False):
|
||||
self.debug = debug
|
||||
self.force = force
|
||||
self.changes = {}
|
||||
|
||||
def validate_binaries_exist(self):
|
||||
for f in (self.checkzone_bin, self.compilezone_bin, self.rndc_bin):
|
||||
if not os.path.isfile(f) and not self.debug:
|
||||
print(f"ERROR: no such file {f}, exiting...")
|
||||
sys.exit(1)
|
||||
|
||||
def run_commands(self, commands):
|
||||
"""Execute commands in order, stopping at the first failure.
|
||||
|
||||
Returns (success, failed_command, output). On success, failed_command
|
||||
and output are empty strings.
|
||||
"""
|
||||
for command in commands:
|
||||
status, output = getstatusoutput(command)
|
||||
if status != 0:
|
||||
return False, command, output
|
||||
return True, "", ""
|
||||
|
||||
@property
|
||||
def frozen_marker(self):
|
||||
"""Exists from just before rndc freeze until the zones are thawed.
|
||||
|
||||
A sync killed in between (SIGKILL, the OOM killer) leaves it behind, and the
|
||||
next sync thaws the zones it finds frozen.
|
||||
"""
|
||||
return os.path.join(self.checkzone_dir, "zones-frozen")
|
||||
|
||||
def frozen_since(self):
|
||||
"""When a sync that never thawed froze the zones, or None."""
|
||||
try:
|
||||
return datetime.fromtimestamp(os.path.getmtime(self.frozen_marker))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def freeze(self):
|
||||
"""Stop dynamic updates and flush each dynamic zone's journal to its file.
|
||||
|
||||
Must run before zone files are written: freeze dumps the zone BIND
|
||||
holds in memory, which would overwrite a file written earlier.
|
||||
"""
|
||||
with open(self.frozen_marker, "w") as f:
|
||||
f.write(f"{os.getpid()}\n")
|
||||
return self.run_commands([self.freeze_command])
|
||||
|
||||
def thaw(self):
|
||||
"""Resume dynamic updates, which a frozen zone refuses (ACME dns-01 among them)."""
|
||||
result = self.run_commands([self.thaw_command])
|
||||
if result[0]:
|
||||
try:
|
||||
os.unlink(self.frozen_marker)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return result
|
||||
|
||||
def reload(self):
|
||||
"""Load the written zone files and resume dynamic updates.
|
||||
|
||||
Thaws even when the reload fails, and reports the first failure.
|
||||
"""
|
||||
reloaded = self.run_commands([self.reload_command])
|
||||
thawed = self.thaw()
|
||||
return reloaded if not reloaded[0] else thawed
|
||||
|
||||
def find_zone(self, name):
|
||||
for model in (Domain, Ip4Subnet, Ip6Subnet):
|
||||
try:
|
||||
return model.objects.get(domain_name=name)
|
||||
except model.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def pending_zones(self):
|
||||
return [
|
||||
zone
|
||||
for zone in chain(
|
||||
Domain.objects.all(), Ip4Subnet.objects.all(), Ip6Subnet.objects.all()
|
||||
)
|
||||
if zone.domain_serial != zone.domain_active_serial or self.force
|
||||
]
|
||||
|
||||
def sync_zone(self, zone):
|
||||
zonetype = getattr(zone._meta, "verbose_name", None) or zone._meta.model_name
|
||||
|
||||
try:
|
||||
with open(zone.domain_filename) as f:
|
||||
old = f.read()
|
||||
except OSError:
|
||||
old = ""
|
||||
|
||||
# Dynamic updates (ACME dns-01 challenges) raise the serial in the
|
||||
# frozen file past the one mdb generated. Secondaries ignore a zone
|
||||
# whose serial does not increase.
|
||||
live_serial = self.read_serial(old)
|
||||
if live_serial is not None and live_serial >= zone.domain_serial:
|
||||
zone.domain_serial = live_serial + 1
|
||||
|
||||
self.changes[zone.domain_name] = {
|
||||
"old_serial": zone.domain_active_serial,
|
||||
"new_serial": zone.domain_serial,
|
||||
"success": False,
|
||||
"errors": None,
|
||||
"diff": None,
|
||||
}
|
||||
|
||||
print(
|
||||
f"updating {zonetype} {zone.domain_name} "
|
||||
f"[{zone.domain_active_serial:d} -> {zone.domain_serial:d}]"
|
||||
)
|
||||
|
||||
# Normalize to BIND's canonical format so the on-disk file and our
|
||||
# generated content always use the same format.
|
||||
new = self.normalize_zone(zone, zone.zone_file_contents())
|
||||
|
||||
self.changes[zone.domain_name]["diff"] = self.calculate_diff(new, old, zone)
|
||||
|
||||
sys.stdout.write("\t- validating... ")
|
||||
status, output = self.check_zone(zone, new)
|
||||
if status != 0:
|
||||
sys.stdout.write("fail\n")
|
||||
self.changes[zone.domain_name]["errors"] = output
|
||||
return False
|
||||
|
||||
sys.stdout.write("ok\n")
|
||||
sys.stdout.write("\t- writing zone file... ")
|
||||
|
||||
if not self.debug:
|
||||
zonefile = zone.domain_filename
|
||||
else:
|
||||
zonefile = self.debug_dir + zone.domain_filename
|
||||
path = "/".join(zonefile.split("/")[:-1])
|
||||
os.path.isdir(path) or os.makedirs(path)
|
||||
|
||||
self.write_zone_file(zonefile, new)
|
||||
|
||||
if not self.debug:
|
||||
zone.domain_active_serial = zone.domain_serial
|
||||
zone.save()
|
||||
|
||||
sys.stdout.write("ok\n")
|
||||
self.changes[zone.domain_name]["success"] = True
|
||||
return True
|
||||
|
||||
def write_zone_file(self, path, contents):
|
||||
"""Replace the file at path with contents, whole or not at all.
|
||||
|
||||
named may read the file at any time, and a write in place could leave it half
|
||||
written. The new file takes the old one's owner and mode: bind owns the zone
|
||||
files, and this runs as root.
|
||||
"""
|
||||
directory, name = os.path.split(path)
|
||||
fd, tmp = tempfile.mkstemp(dir=directory or ".", prefix=f".{name}.")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(contents)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
os.chmod(tmp, 0o644)
|
||||
else:
|
||||
os.chmod(tmp, stat.S_IMODE(st.st_mode))
|
||||
if (st.st_uid, st.st_gid) != (os.getuid(), os.getgid()):
|
||||
os.chown(tmp, st.st_uid, st.st_gid)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def read_serial(self, contents):
|
||||
"""Return the SOA serial of a zone file, or None if there is none."""
|
||||
match = re.search(r"^\s*(\d+)\s*;\s*serial", contents, re.MULTILINE)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
def normalize_zone(self, zone, contents):
|
||||
"""Convert zone file contents to BIND's canonical format.
|
||||
|
||||
Uses named-compilezone to produce the same format that rndc freeze
|
||||
writes, so subsequent freeze/thaw cycles won't reformat the file and
|
||||
cause spurious diffs. Falls back to the original contents if
|
||||
named-compilezone is unavailable or fails.
|
||||
"""
|
||||
if not os.path.isfile(self.compilezone_bin):
|
||||
return contents
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".zone", delete=False) as f:
|
||||
f.write(contents)
|
||||
tmpfile = f.name
|
||||
|
||||
try:
|
||||
cmd = f"{self.compilezone_bin} -f text -F text -s relative -i none -o - {zone.domain_name} {tmpfile}"
|
||||
status, output = getstatusoutput(cmd + " 2>/dev/null")
|
||||
if status == 0:
|
||||
return output
|
||||
return contents
|
||||
finally:
|
||||
os.unlink(tmpfile)
|
||||
|
||||
def calculate_diff(self, new, old, zone):
|
||||
return "\n".join(
|
||||
context_diff(
|
||||
a=old.splitlines(),
|
||||
b=new.splitlines(),
|
||||
fromfile=str(zone.domain_active_serial) + "-" + zone.domain_name,
|
||||
tofile=str(zone.domain_serial) + "-" + zone.domain_name,
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
|
||||
def check_zone(self, zone, contents):
|
||||
zonefile = self.checkzone_dir + "/" + zone.domain_name
|
||||
with open(zonefile, "w") as f:
|
||||
f.write(contents)
|
||||
|
||||
return getstatusoutput(f"{self.checkzone_bin} {zone.domain_name} {zonefile}")
|
||||
+29
-36
@@ -1,12 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import pre_delete, post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
import ipaddress
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from mdb.models import Interface, Host, Ip6Subnet, Ip4Subnet, Ip4Address
|
||||
from django.db.models.signals import post_save, pre_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from mdb.models import PXE_FILENAME, Host, Interface, Ip4Address, Ip4Subnet, Ip6Subnet
|
||||
from mdb.utils import format_domain_serial_and_add_one
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,9 +33,7 @@ def delete_ips_for_subnet(sender, instance, **kwargs):
|
||||
def set_domain_name_for_subnet(sender, instance, **kwargs):
|
||||
# we assume that the reverse domain_name does not change
|
||||
if len(instance.domain_name) == 0:
|
||||
ipspl = instance.network.split(".")
|
||||
rev = "%s.%s.%s" % (ipspl[2], ipspl[1], ipspl[0])
|
||||
instance.domain_name = "%s.in-addr.arpa" % rev
|
||||
instance.domain_name = instance.reverse_zone_name
|
||||
|
||||
# update it's own serial
|
||||
# if instance.domain_serial is not None:
|
||||
@@ -46,7 +43,9 @@ def set_domain_name_for_subnet(sender, instance, **kwargs):
|
||||
# when the subnet is changed
|
||||
if instance.dhcp_config:
|
||||
# instance.dhcp_config.serial = instance.dhcp_config.serial + 1
|
||||
instance.dhcp_config.serial = format_domain_serial_and_add_one(instance.dhcp_config.serial)
|
||||
instance.dhcp_config.serial = format_domain_serial_and_add_one(
|
||||
instance.dhcp_config.serial
|
||||
)
|
||||
instance.dhcp_config.save()
|
||||
|
||||
|
||||
@@ -55,8 +54,7 @@ def set_domain_name_for_ipv6_subnet(sender, instance, **kwargs):
|
||||
if len(instance.domain_name) > 0:
|
||||
return
|
||||
|
||||
network = ipaddress.IPv6Address("%s::" % instance.network)
|
||||
instance.domain_name = ".".join(network.exploded.replace(":", "")[:16])[::-1] + ".ip6.arpa"
|
||||
instance.domain_name = instance.reverse_zone_name
|
||||
|
||||
|
||||
@receiver(post_save, sender=Interface)
|
||||
@@ -77,16 +75,22 @@ def update_domain_serial_when_change_to_host(sender, instance, created, **kwargs
|
||||
for interface in instance.interface_set.all():
|
||||
if interface.domain is not None:
|
||||
domain = interface.domain
|
||||
domain.domain_serial = format_domain_serial_and_add_one(domain.domain_serial)
|
||||
domain.domain_serial = format_domain_serial_and_add_one(
|
||||
domain.domain_serial
|
||||
)
|
||||
domain.save()
|
||||
if interface.ip4address is not None:
|
||||
subnet = interface.ip4address.subnet
|
||||
subnet.domain_serial = format_domain_serial_and_add_one(subnet.domain_serial)
|
||||
subnet.domain_serial = format_domain_serial_and_add_one(
|
||||
subnet.domain_serial
|
||||
)
|
||||
subnet.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Host)
|
||||
def create_pxe_key_and_write_pxe_files_when_host_changes(sender, instance, created, **kwargs):
|
||||
def set_pxe_boot_when_host_changes(sender, instance, created, **kwargs):
|
||||
"""An installable host netboots into the menu; unticking it stops that.
|
||||
A filename other than pxelinux's is left alone either way."""
|
||||
host = instance
|
||||
|
||||
if not host.pxe_key:
|
||||
@@ -95,46 +99,35 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(sender, instance, creat
|
||||
Host.objects.filter(pk=host.pk).update(pxe_key=pxe_key)
|
||||
host.pxe_key = pxe_key
|
||||
|
||||
for pxe_file_name, pxe_file in host.as_pxe_files():
|
||||
path = os.path.join(settings.MDB_PXE_TFTP_ROOT, pxe_file_name)
|
||||
if host.pxe_installable:
|
||||
with open(path, 'w+') as f:
|
||||
f.write(pxe_file)
|
||||
logger.info("Created or updated {}".format(path))
|
||||
|
||||
# Also set necessary fields for pxe installation
|
||||
interfaces = host.interface_set.exclude(ip4address__isnull=True)
|
||||
|
||||
for _if in interfaces:
|
||||
for _if in host.interface_set.exclude(ip4address__isnull=True):
|
||||
changed = False
|
||||
if host.pxe_installable:
|
||||
if not _if.pxe_filename:
|
||||
_if.pxe_filename = 'pxelinux.0' # don't overwrite
|
||||
_if.pxe_filename = PXE_FILENAME
|
||||
changed = True
|
||||
if not _if.dhcp_client:
|
||||
_if.dhcp_client = True
|
||||
changed = True
|
||||
|
||||
elif _if.pxe_filename == PXE_FILENAME:
|
||||
_if.pxe_filename = ""
|
||||
changed = True
|
||||
if changed:
|
||||
_if.save()
|
||||
|
||||
else:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
logger.info("deleted {}".format(path))
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Interface)
|
||||
def update_domain_serial_when_interface_deleted(sender, instance, **kwargs):
|
||||
if instance.domain is not None:
|
||||
domain = instance.domain
|
||||
domain.domain_serial += 1
|
||||
domain.domain_serial = format_domain_serial_and_add_one(domain.domain_serial)
|
||||
domain.save()
|
||||
|
||||
if instance.ip4address is not None:
|
||||
subnet = instance.ip4address.subnet
|
||||
subnet.domain_serial += 1
|
||||
subnet.domain_serial = format_domain_serial_and_add_one(subnet.domain_serial)
|
||||
subnet.save()
|
||||
|
||||
|
||||
# @receiver(pre_save, sender=Domain)
|
||||
# def update_domain_serial_when_domain_is_saved(sender, instance, **kwargs):
|
||||
# instance.domain_serial = format_domain_serial_and_add_one(instance.domain_serial)
|
||||
@@ -1,4 +0,0 @@
|
||||
kernel {{kernel}}
|
||||
append ramdisk_size=14984 locale=en_US console-setup/ask_detect=false keyboard-configuration/layoutcode=no netcfg/wireless_wep= netcfg_choose_interface=eth0 netcfg/get_hostname= preseed/url={{preseed_config_url}} vga=normal initrd={{initrd}} -- snop={{host.pxe_key}}
|
||||
prompt 0
|
||||
timeout 0
|
||||
@@ -1,56 +0,0 @@
|
||||
import io
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from mdb.models import Host
|
||||
|
||||
|
||||
class MyAPITestCases(APITestCase):
|
||||
fixtures = ['test_data']
|
||||
|
||||
def create_user_with_token(self):
|
||||
self.user = User.objects.create(username='admin')
|
||||
self.token = Token.objects.create(user=self.user)
|
||||
self.host = Host.objects.first()
|
||||
|
||||
def setUp(self):
|
||||
self.create_user_with_token()
|
||||
return super(MyAPITestCases, self).setUp()
|
||||
|
||||
def test_validate_puppet_host_by_secret(self):
|
||||
data = {
|
||||
'pxe_key': self.host.pxe_key,
|
||||
'certname': '{}.{}'.format(self.host.hostname, self.host.interface_set.first().domain.domain_name)
|
||||
}
|
||||
self.client.credentials(HTTP_AUTHORIZATION='Token {}'.format(self.token.key))
|
||||
res = self.client.post(reverse('api-validate-host-secret'), data, format='json')
|
||||
self.assertEquals(res.status_code, 200, res.content)
|
||||
self.assertFalse(Host.objects.get(pk=self.host.pk).pxe_installable)
|
||||
|
||||
|
||||
class UnitTests(TestCase):
|
||||
fixtures = ['test_data']
|
||||
|
||||
def test_host_as_pxe_files(self):
|
||||
host = Host.objects.first()
|
||||
self.assertEqual(len(host.as_pxe_files()), 1)
|
||||
|
||||
|
||||
class RunManagementCommands(TestCase):
|
||||
fixtures = ['test_data']
|
||||
|
||||
def test_zone_sync(self):
|
||||
expected_zone_file = ''
|
||||
with io.BytesIO() as f:
|
||||
call_command('zone-sync', debug=True, stdout=f)
|
||||
# TODO check expected_zone_file
|
||||
|
||||
def test_dhcp_sync(self):
|
||||
expected_dhcp_file = ''
|
||||
with io.BytesIO() as f:
|
||||
call_command('dhcp-sync', stdout=f)
|
||||
# TODO check expected_dhcp_file
|
||||
+10
-75
@@ -1,81 +1,16 @@
|
||||
import datetime
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
|
||||
def format_domain_serial_and_add_one(serial):
|
||||
today = datetime.datetime.now()
|
||||
res = re.findall("^%4d%02d%02d(\d\d)$" % (
|
||||
today.year, today.month, today.day), str(serial), re.DOTALL)
|
||||
"""Return the serial after `serial`, in the YYYYMMDDnn form where it can be.
|
||||
|
||||
if len(res) == 0:
|
||||
""" This probably means that the serial is malformed
|
||||
or the date is wrong. We assume that if the date is wrong,
|
||||
it is in the past. Just create a new serial starting from 1."""
|
||||
return "%4d%02d%02d%02d" % \
|
||||
(today.year, today.month, today.day, 1)
|
||||
elif len(res) == 1:
|
||||
""" The serial contains todays date, just update it. """
|
||||
The serial must always increase, or named and dhcpd ignore the change. Today's first
|
||||
serial is used while it is higher. Otherwise the serial goes up by one: past nn = 99
|
||||
it carries into the date (2026110299 -> 2026110300), and a serial already ahead of
|
||||
today, from that carry or a dynamic update, keeps counting from where it is.
|
||||
"""
|
||||
today_first = int(datetime.date.today().strftime("%Y%m%d")) * 100 + 1
|
||||
try:
|
||||
number = int(res[0])
|
||||
except:
|
||||
number = 1
|
||||
if number >= 99:
|
||||
""" This is bad... Just keep the number on 99.
|
||||
We also send a mail to sysadmins telling them that
|
||||
something is wrong..."""
|
||||
|
||||
else:
|
||||
number += 1
|
||||
return "%4d%02d%02d%02d" % (
|
||||
today.year, today.month, today.day, number)
|
||||
else:
|
||||
""" Just return the first serial for today. """
|
||||
return "%4d%02d%02d%02d" % \
|
||||
(today.year, today.month, today.day, 1)
|
||||
|
||||
|
||||
def render_pxelinux_cfg(_if, host):
|
||||
# TODO add fields to os model and make dynamic per host
|
||||
distro = 'ubuntu/14_04/amd64/alternate/'
|
||||
kernel = distro + 'linux'
|
||||
initrd = distro + 'initrd.gz'
|
||||
preseed_config_url = settings.MDB_PXE_PRESEED_URL
|
||||
context = {
|
||||
'kernel': kernel,
|
||||
'initrd': initrd,
|
||||
'preseed_config_url': preseed_config_url,
|
||||
'host': host,
|
||||
'interface': _if
|
||||
}
|
||||
return render_to_string('pxelinux.cfg', context=context)
|
||||
|
||||
|
||||
def get_pxe_filename(interface, filename_format='mac_addr'):
|
||||
"""
|
||||
Mac or IP address based PXE filename
|
||||
Ref: http://www.syslinux.org/wiki/index.php/PXELINUX#Examples
|
||||
"""
|
||||
ip_addr_in_hex = '{:02X}'.format(int(ipaddress.IPv4Address(interface.ip4address.address)))
|
||||
formats = {
|
||||
'ip_addr': ip_addr_in_hex,
|
||||
'mac_addr': '01-{}'.format(interface.macaddr.lower().replace(':', '-'))
|
||||
}
|
||||
|
||||
return formats.get(filename_format, formats['mac_addr'])
|
||||
|
||||
|
||||
def host_as_pxe_files(host):
|
||||
""" Returns a list of filename,content for each host interface with an IPv4 address """
|
||||
from mdb.models import Host
|
||||
assert isinstance(host, Host)
|
||||
|
||||
pxe_files = []
|
||||
for _if in host.interface_set.exclude(ip4address__isnull=True):
|
||||
filename = get_pxe_filename(_if)
|
||||
content = render_pxelinux_cfg(_if, host)
|
||||
pxe_files.append((filename, content))
|
||||
|
||||
return pxe_files
|
||||
return max(int(serial) + 1, today_first)
|
||||
except TypeError, ValueError:
|
||||
return today_first
|
||||
+10
-12
@@ -1,16 +1,14 @@
|
||||
from __future__ import unicode_literals
|
||||
from django.core.validators import RegexValidator
|
||||
import re
|
||||
|
||||
# http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
|
||||
hostname_re = re.compile(r'^(?!-)[-a-z0-9]+(?<!-)$', re.IGNORECASE)
|
||||
validate_hostname = RegexValidator(
|
||||
regex=hostname_re,
|
||||
message='Enter a valid hostname',
|
||||
code='invalid')
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
macaddr_re = re.compile(r'^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$', re.IGNORECASE)
|
||||
# http://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names
|
||||
hostname_re = re.compile(r"^(?!-)[-a-z0-9]+(?<!-)$", re.IGNORECASE)
|
||||
validate_hostname = RegexValidator(
|
||||
regex=hostname_re, message="Enter a valid hostname", code="invalid"
|
||||
)
|
||||
|
||||
macaddr_re = re.compile(r"^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$", re.IGNORECASE)
|
||||
validate_macaddr = RegexValidator(
|
||||
regex=macaddr_re,
|
||||
message='Enter a valid MAC address',
|
||||
code='invalid')
|
||||
regex=macaddr_re, message="Enter a valid MAC address", code="invalid"
|
||||
)
|
||||
+108
-95
@@ -1,131 +1,144 @@
|
||||
import os
|
||||
from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS as TCP
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
DEBUG = True
|
||||
TEMPLATE_DEBUG = DEBUG
|
||||
# Off unless DJANGO_DEBUG says otherwise; mise.toml turns it on for development.
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "").lower() in ("1", "true", "yes")
|
||||
|
||||
ADMINS = (
|
||||
('System Administrator', 'sysadmin@example.com'),
|
||||
)
|
||||
ADMINS = ("sysadmin@example.com",)
|
||||
|
||||
MANAGERS = ADMINS
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': 'db.sqlite3',
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": "db.sqlite3",
|
||||
}
|
||||
}
|
||||
TIME_ZONE = 'Europe/Oslo'
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = "Europe/Oslo"
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
MEDIA_URL = '/media/'
|
||||
ADMIN_MEDIA_PREFIX = '/media/'
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
|
||||
STATIC_URL = '/static/'
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
MEDIA_URL = "/media/"
|
||||
|
||||
|
||||
# Additional locations of static files
|
||||
STATICFILES_DIRS = (
|
||||
)
|
||||
|
||||
# List of finder classes that know how to find static files in
|
||||
# various locations.
|
||||
# Static
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_FINDERS = (
|
||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||
# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
)
|
||||
|
||||
# Make this unique, and don't share it with anybody.
|
||||
SECRET_KEY = '@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl'
|
||||
# For development only: the repository is public. Production sets its own (MDB_SETTINGS).
|
||||
SECRET_KEY = "@4ps=ebofcpv8sb#y7gm896=l&l*u0u-pgo7bcdj%+(k+2lqnl"
|
||||
|
||||
MIDDLEWARE_CLASSES = (
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
MIDDLEWARE = (
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
# Serves STATIC_ROOT, so production needs nothing in front but a proxy.
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
)
|
||||
|
||||
ROOT_URLCONF = 'mdbsite.urls'
|
||||
ROOT_URLCONF = "mdbsite.urls"
|
||||
|
||||
TEMPLATE_DIRS = (
|
||||
# Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
|
||||
# Always use forward slashes, even on Windows.
|
||||
# Don't forget to use absolute paths, not relative paths.
|
||||
)
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.template.context_processors.i18n",
|
||||
"django.template.context_processors.media",
|
||||
"django.template.context_processors.static",
|
||||
"django.template.context_processors.tz",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
INSTALLED_APPS = (
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'suit',
|
||||
'django.contrib.admin',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
|
||||
'mdb',
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.sites",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.admin",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"django_extensions",
|
||||
"mdb",
|
||||
)
|
||||
|
||||
EMAIL_SUBJECT_PREFIX = '[MDB] '
|
||||
EMAIL_SUBJECT_PREFIX = "[MDB] "
|
||||
|
||||
TEMPLATE_CONTEXT_PROCESSORS = TCP + (
|
||||
'django.core.context_processors.request', # for django-suit
|
||||
)
|
||||
|
||||
# django-suit configuration
|
||||
SUIT_CONFIG = {
|
||||
# header
|
||||
'ADMIN_NAME': 'MDB',
|
||||
'HEADER_DATE_FORMAT': 'l, Y-m-d',
|
||||
'HEADER_TIME_FORMAT': 'H:i',
|
||||
|
||||
# forms
|
||||
# 'SHOW_REQUIRED_ASTERISK': True, # Default True
|
||||
# 'CONFIRM_UNSAVED_CHANGES': True, # Default True
|
||||
|
||||
# menu
|
||||
'SEARCH_URL': '/admin/mdb/host/',
|
||||
# 'MENU_ICONS': {
|
||||
# 'sites': 'icon-leaf',
|
||||
# 'auth': 'icon-lock',
|
||||
# },
|
||||
# 'MENU_OPEN_FIRST_CHILD': True, # Default True
|
||||
# 'MENU_EXCLUDE': ('auth.group',),
|
||||
'MENU': (
|
||||
{'app': 'mdb', 'label': 'MDB', 'url': 'mdb.host'},
|
||||
{
|
||||
'label': 'Settings',
|
||||
'icon': 'icon-cog',
|
||||
'models': ('auth.user', 'auth.group')
|
||||
# Logging
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": True,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "%(levelname)s %(asctime)s %(module)s "
|
||||
"%(process)d %(thread)d %(message)s"
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
"root": {
|
||||
"level": "WARNING",
|
||||
"handlers": ["console"],
|
||||
},
|
||||
"django.db.backends": {
|
||||
"level": "ERROR",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
# misc
|
||||
'LIST_PER_PAGE': 30
|
||||
}
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',),
|
||||
'PAGINATE_BY': 10
|
||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||
}
|
||||
|
||||
MDB_PXE_TFTP_ROOT = '/var/lib/tftpboot/pxelinux/pxelinux.cfg/'
|
||||
MDB_PXE_PRESEED_URL = 'http://158.36.190.194/ubuntu/preseed_1404.cfg'
|
||||
# Sentry
|
||||
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
||||
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "dev")
|
||||
|
||||
try:
|
||||
from .local_settings import *
|
||||
except ImportError:
|
||||
pass
|
||||
ADMIN_SITE_NAME = "MDB"
|
||||
|
||||
# Production's settings live outside the code, in the file MDB_SETTINGS names; the mdb role
|
||||
# in edb-infra writes it. An error in it stops MDB rather than leaving the defaults above.
|
||||
if os.getenv("MDB_SETTINGS"):
|
||||
with open(os.environ["MDB_SETTINGS"]) as f:
|
||||
exec(compile(f.read(), os.environ["MDB_SETTINGS"], "exec"))
|
||||
else:
|
||||
# Development. Only a missing local_settings is skipped: a local_settings whose own
|
||||
# imports fail must not leave MDB running on the defaults.
|
||||
try:
|
||||
from .local_settings import * # noqa: F403
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name != "mdbsite.local_settings":
|
||||
raise
|
||||
+9
-4
@@ -1,15 +1,20 @@
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
from django.urls import path
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from mdb.api.views import HostPXEValidate
|
||||
|
||||
admin.autodiscover()
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^$', RedirectView.as_view(url='admin/', permanent=True)),
|
||||
url(r'^admin/', include(admin.site.urls)),
|
||||
url(r'^api/validate-host-secret/', HostPXEValidate.as_view(), name='api-validate-host-secret')
|
||||
path("", RedirectView.as_view(url="admin/", permanent=True)),
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"api/validate-host-secret/",
|
||||
HostPXEValidate.as_view(),
|
||||
name="api-validate-host-secret",
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns += staticfiles_urlpatterns()
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdb.settings")
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mdbsite.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,8 @@
|
||||
[tools]
|
||||
python = "3.14"
|
||||
uv = "latest"
|
||||
prek = "latest" # git hooks, see prek.toml
|
||||
|
||||
[env]
|
||||
# settings.py leaves DEBUG off unless this says otherwise.
|
||||
DJANGO_DEBUG = "1"
|
||||
@@ -0,0 +1,32 @@
|
||||
# Git hooks, run by prek (https://prek.j178.dev). `prek install` once per clone; CI runs
|
||||
# them all (.gitea/workflows/ci.yaml).
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
rev = "v0.17.3" # keep in sync with ruff in uv.lock
|
||||
hooks = [
|
||||
{ id = "ruff-check", args = ["--fix"] },
|
||||
{ id = "ruff-format" },
|
||||
]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/adamchainz/django-upgrade"
|
||||
rev = "1.33.0"
|
||||
hooks = [{ id = "django-upgrade", args = ["--target-version", "6.1"] }]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/pre-commit/pre-commit-hooks"
|
||||
rev = "v6.0.0"
|
||||
hooks = [
|
||||
{ id = "trailing-whitespace" },
|
||||
{ id = "end-of-file-fixer" },
|
||||
{ id = "check-merge-conflict" },
|
||||
{ id = "check-added-large-files" },
|
||||
{ id = "check-toml" },
|
||||
{ id = "check-yaml" },
|
||||
]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/astral-sh/uv-pre-commit"
|
||||
rev = "0.13.6"
|
||||
hooks = [{ id = "uv-lock" }] # uv.lock stays in sync with pyproject.toml
|
||||
@@ -0,0 +1,59 @@
|
||||
[project]
|
||||
name = "mdb"
|
||||
version = "0.1.0"
|
||||
description = "Machine Database - A Django app for managing computer equipment"
|
||||
authors = [
|
||||
{ name = "EDB", email = "edb@neuf.no" },
|
||||
]
|
||||
requires-python = ">=3.14,<3.15"
|
||||
license = "GPL-2.0-or-later"
|
||||
dependencies = [
|
||||
"django>=6.1.2",
|
||||
"django-extensions>=4.1",
|
||||
"djangorestframework>=3.18.3",
|
||||
"gunicorn>=26.2.0",
|
||||
"requests>=2.34.2",
|
||||
"sentry-sdk>=2.73.0",
|
||||
"whitenoise>=6.12,<7",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
# Only needed on the server (LDAP is configured in production's settings).
|
||||
# Kept separate so CI can skip building python-ldap from source.
|
||||
prod = [
|
||||
"django-auth-ldap>=5.4.0",
|
||||
]
|
||||
dev = [
|
||||
"freezegun>=1.5.5",
|
||||
"pytest>=9.2.0",
|
||||
"pytest-django>=4.14.0",
|
||||
"ruff>=0.17.3,<1.0.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = ["dev", "prod"]
|
||||
|
||||
[tool.ruff]
|
||||
exclude = ["mdb/migrations/**"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line-too-long
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "mdbsite.settings"
|
||||
python_files = ["tests/test_*.py"]
|
||||
# WhiteNoise, since tests run without collectstatic.
|
||||
filterwarnings = ["ignore:No directory at:UserWarning"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -1,7 +0,0 @@
|
||||
Django>=1.8,<1.9
|
||||
django-auth-ldap
|
||||
django-extensions==1.5.7
|
||||
djangorestframework==3.2.4
|
||||
django-suit==0.2.15
|
||||
ipaddress==1.0.14
|
||||
mysqlclient==1.3.6
|
||||
@@ -1,8 +0,0 @@
|
||||
To use this scripts, you have to add the projects location to your environment
|
||||
pythonpath. This is easily archieved by setting an environment variable
|
||||
like this:
|
||||
|
||||
export PYTHONPATH=/opt/projects/:/opt/projects/dns_mdb
|
||||
|
||||
It is important that both the path above the project and the project
|
||||
folder itself are added to the path before the script is run.
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
TMP1=$(bash -c "cd .. ; pwd ; echo \":\" ; cd .. ; pwd")
|
||||
TMP2=$(echo $TMP1 | sed -e "s/ //g")
|
||||
|
||||
echo "Run the following to setup the python environment."
|
||||
echo "export PYTHONPATH=$TMP2"
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# coding: utf-8
|
||||
|
||||
import os,sys,datetime,argparse,threading,time,Queue
|
||||
from commands import getstatusoutput
|
||||
|
||||
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
|
||||
|
||||
parser.add_argument('-t', '--type', default='all',\
|
||||
help="Which type of host to operate on")
|
||||
parser.add_argument('-d', '--debug', action='store_true',\
|
||||
help="Turn on debugging")
|
||||
parser.add_argument('--show-types', dest='show_types', action='store_true',\
|
||||
help='Show available host types')
|
||||
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
|
||||
help='Number of threads used for pinging hosts')
|
||||
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
|
||||
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
#project_path = '/opt/django/dns_mdb/'
|
||||
#if project_path not in sys.path:
|
||||
# sys.path.append(project_path)
|
||||
|
||||
from django.core.management import setup_environ
|
||||
from django.core.mail import mail_admins
|
||||
from dns_mdb import settings
|
||||
setup_environ(settings)
|
||||
from mdb.models import *
|
||||
|
||||
PING_CMD='ping -W 1 -c %d -n -q %s | grep rtt | cut -d " " -f4'
|
||||
|
||||
queue = Queue.Queue()
|
||||
|
||||
|
||||
class PingThread(threading.Thread):
|
||||
""" Threaded ping """
|
||||
def __init__(self, host_queue):
|
||||
threading.Thread.__init__(self)
|
||||
self.queue = queue
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
host = self.queue.get()
|
||||
self.do_ping_host(host)
|
||||
self.queue.task_done()
|
||||
|
||||
def do_ping_host(self, host):
|
||||
interfaces = host.interface_set.all()
|
||||
for interface in interfaces:
|
||||
self.do_ping_interface(interface, host)
|
||||
|
||||
def do_ping_interface(self, interface, host):
|
||||
res = self.do_ping_ipaddr(interface.ip4address.address)
|
||||
if res == None:
|
||||
if args.debug:
|
||||
print "%s (%s/%s) : fail " % (host, \
|
||||
interface.name, interface.ip4address.address)
|
||||
return
|
||||
interface.ip4address.last_contact = datetime.datetime.now()
|
||||
interface.ip4address.ping_avg_rtt = float(res[1])
|
||||
interface.ip4address.save()
|
||||
if args.debug:
|
||||
print "%s (%s/%s) : %s" % (host, interface.name, \
|
||||
interface.ip4address.address, res)
|
||||
|
||||
def do_ping_ipaddr(self, ipaddr):
|
||||
status, output = getstatusoutput(PING_CMD % (args.num_pings,ipaddr))
|
||||
if status == 0 and len(output) > 0:
|
||||
return output.split("/")
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def show_host_types():
|
||||
types = HostType.objects.all()
|
||||
for t in types:
|
||||
print "%s (%s)" % (t.host_type, t.description)
|
||||
|
||||
if args.show_types:
|
||||
show_host_types()
|
||||
sys.exit(0)
|
||||
|
||||
if args.type == "all":
|
||||
hosts = Host.objects.all()
|
||||
else:
|
||||
hosts = Host.objects.filter(host_type__host_type = args.type)
|
||||
|
||||
# start num_threads threads for pinging
|
||||
for i in xrange(args.num_threads):
|
||||
t = PingThread(queue)
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
if args.debug:
|
||||
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
|
||||
|
||||
# Add all hosts to the queue. Ping threads
|
||||
# will pick hosts from this queue.
|
||||
for host in hosts:
|
||||
queue.put(host)
|
||||
|
||||
# Wait for all threads to finish, eg. queue is empty.
|
||||
queue.join()
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# coding: utf-8
|
||||
|
||||
import os,sys,datetime,argparse,threading,time,Queue
|
||||
from commands import getstatusoutput
|
||||
|
||||
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
|
||||
|
||||
parser.add_argument('-t', '--type', default='all',\
|
||||
help="Which type of host to operate on")
|
||||
parser.add_argument('-d', '--debug', action='store_true',\
|
||||
help="Turn on debugging")
|
||||
parser.add_argument('--show-types', dest='show_types', action='store_true',\
|
||||
help='Show available host types')
|
||||
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
|
||||
help='Number of threads used for pinging hosts')
|
||||
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
|
||||
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
|
||||
parser.add_argument('--rrd-path', dest='rrd_path', required=True,\
|
||||
help='Specify the path to the rrd files')
|
||||
parser.add_argument('--graph-path', dest='graph_path', required=True,\
|
||||
help="Specify the output directory for the graphs")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
from django.core.management import setup_environ
|
||||
from django.core.mail import mail_admins
|
||||
from dns_mdb import settings
|
||||
setup_environ(settings)
|
||||
from mdb.models import *
|
||||
|
||||
queue = Queue.Queue()
|
||||
|
||||
RRD_GRAPH = "%s " \
|
||||
"-w 785 -h 120 -a PNG " \
|
||||
"--slope-mode " \
|
||||
"--start -86400 --end now " \
|
||||
"--font DEFAULT:7: "\
|
||||
"--title \"%s\" " \
|
||||
"--watermark \"NEUF.NO\" " \
|
||||
"--vertical-label \"latency(ms)\" " \
|
||||
"--right-axis-label \"latency(ms)\" " \
|
||||
"--lower-limit 0 " \
|
||||
"--right-axis 1:0 " \
|
||||
"--x-grid MINUTE:10:HOUR:1:MINUTE:120:0:%%R " \
|
||||
"--alt-y-grid --rigid " \
|
||||
"DEF:roundtrip=%s:rrd:AVERAGE " \
|
||||
"LINE1:roundtrip#0000FF:\"latency(ms)\" "
|
||||
|
||||
# RRD_GRAPH ARGS: <output_filename.png>, <title>, <rrd>
|
||||
|
||||
RRD_DIR = args.rrd_path
|
||||
RRD_GRAPH_DIR = args.graph_path
|
||||
RRDTOOL = "/usr/bin/rrdtool"
|
||||
|
||||
class RrdService():
|
||||
def log(self, host, interface, avg_ping):
|
||||
rrd = self.get_rrd(host, interface)
|
||||
self.update_rrd(rrd, avg_ping)
|
||||
|
||||
def get_rrd(self, host, interface):
|
||||
return "%s/%s_%s.rrd" % ( RRD_DIR, host.id,\
|
||||
interface.id)
|
||||
|
||||
def get_png(self, host, interface):
|
||||
return "%s/%s_%s.png" % (RRD_GRAPH_DIR, host.id,\
|
||||
interface.id)
|
||||
|
||||
def update_rrd(self, rrd, avg_ping):
|
||||
self.create_rrd(rrd)
|
||||
|
||||
self.rrd_exec("update", RRD_UPDATE % (rrd, avg_ping))
|
||||
|
||||
def create_rrd(self, rrd):
|
||||
if os.path.isfile(rrd):
|
||||
return True
|
||||
if args.debug:
|
||||
print "Creating rrd: " + rrd
|
||||
if not self.rrd_exec("create", RRD_CREATE % rrd):
|
||||
if args.debug:
|
||||
print "Could not create RRD database."
|
||||
return False
|
||||
return True
|
||||
|
||||
def create_title(self, host, interface):
|
||||
# return "%s (%s)" % (host.hostname, unicode(interface))
|
||||
return unicode(interface)
|
||||
|
||||
def create_graph(self, host, interface):
|
||||
rrd = self.get_rrd(host, interface)
|
||||
if not os.path.isfile(rrd): return
|
||||
|
||||
self.rrd_exec("graph", RRD_GRAPH % (self.get_png(host, interface), \
|
||||
self.create_title(host, interface), rrd))
|
||||
|
||||
def rrd_exec(self, function, rrd_cmd):
|
||||
cmd = "%s %s %s" % (RRDTOOL, function, rrd_cmd)
|
||||
if args.debug:
|
||||
print "RRD_EXEC (%s)" % cmd
|
||||
if os.system(cmd) != 0:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
class WorkerThread(threading.Thread):
|
||||
""" Threaded ping """
|
||||
def __init__(self, host_queue, rrd_service):
|
||||
threading.Thread.__init__(self)
|
||||
self.queue = queue
|
||||
self.rrd_service = rrd_service
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
host = self.queue.get()
|
||||
self.do_graph_host(host)
|
||||
self.queue.task_done()
|
||||
|
||||
def do_graph_host(self, host):
|
||||
interfaces = host.interface_set.all()
|
||||
for interface in interfaces:
|
||||
self.rrd_service.create_graph(host, interface)
|
||||
|
||||
def show_host_types():
|
||||
types = HostType.objects.all()
|
||||
for t in types:
|
||||
print "%s (%s)" % (t.host_type, t.description)
|
||||
|
||||
if args.show_types:
|
||||
show_host_types()
|
||||
sys.exit(0)
|
||||
|
||||
if args.type == "all":
|
||||
hosts = Host.objects.all()
|
||||
else:
|
||||
hosts = Host.objects.filter(host_type__host_type = args.type)
|
||||
|
||||
rrd_service = RrdService()
|
||||
|
||||
# start num_threads threads for pinging
|
||||
for i in xrange(args.num_threads):
|
||||
t = WorkerThread(queue, rrd_service)
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
if args.debug:
|
||||
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
|
||||
|
||||
# Add all hosts to the queue. Ping threads
|
||||
# will pick hosts from this queue.
|
||||
for host in hosts:
|
||||
queue.put(host)
|
||||
|
||||
# Wait for all threads to finish, eg. queue is empty.
|
||||
queue.join()
|
||||
|
||||
sys.exit(0)
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# coding: utf-8
|
||||
|
||||
import os,sys,datetime,argparse,threading,time,Queue
|
||||
from commands import getstatusoutput
|
||||
|
||||
parser = argparse.ArgumentParser(description = 'Ping hosts from mdb')
|
||||
|
||||
parser.add_argument('-t', '--type', default='all',\
|
||||
help="Which type of host to operate on")
|
||||
parser.add_argument('-d', '--debug', action='store_true',\
|
||||
help="Turn on debugging")
|
||||
parser.add_argument('--show-types', dest='show_types', action='store_true',\
|
||||
help='Show available host types')
|
||||
parser.add_argument('--num-threads', dest='num_threads', type=int, default=1,\
|
||||
help='Number of threads used for pinging hosts')
|
||||
parser.add_argument('--num-pings', dest='num_pings', type=int, default=5,\
|
||||
help='Number of ping requests to each host. More requests gives a more accurate average calculation.')
|
||||
parser.add_argument('--rrd-path', dest='rrd_path', required=True,\
|
||||
help='Specify the path to the rrd files')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
from django.core.management import setup_environ
|
||||
from django.core.mail import mail_admins
|
||||
from dns_mdb import settings
|
||||
setup_environ(settings)
|
||||
from mdb.models import *
|
||||
|
||||
PING_CMD='ping -W 1 -c %d -n -q %s | grep rtt | cut -d " " -f4'
|
||||
|
||||
queue = Queue.Queue()
|
||||
|
||||
|
||||
RRD_CREATE = "%s " \
|
||||
"DS:ttl:GAUGE:600:U:U "\
|
||||
"RRA:AVERAGE:0.5:1:576 "\
|
||||
"RRA:AVERAGE:0.5:6:336 "\
|
||||
"RRA:AVERAGE:0.5:72:124 "\
|
||||
"RRA:AVERAGE:0.5:288:365"
|
||||
|
||||
RRD_UPDATE = "%s --template rtt N:%s"
|
||||
|
||||
RRD_DIR = args.rrd_path
|
||||
RRDTOOL = "/usr/bin/rrdtool"
|
||||
|
||||
class RrdService():
|
||||
def log(self, host, interface, avg_ping):
|
||||
rrd = self.get_rrd(host, interface)
|
||||
self.update_rrd(rrd, avg_ping)
|
||||
|
||||
def get_rrd(self, host, interface):
|
||||
return "%s/%s_%s.rrd" % ( RRD_DIR, host.id,\
|
||||
interface.id)
|
||||
|
||||
def update_rrd(self, rrd, avg_ping):
|
||||
self.create_rrd(rrd)
|
||||
|
||||
self.rrd_exec("update", RRD_UPDATE % (rrd, avg_ping))
|
||||
|
||||
def create_rrd(self, rrd):
|
||||
if os.path.isfile(rrd):
|
||||
return True
|
||||
if args.debug:
|
||||
print "Creating rrd: " + rrd
|
||||
if not self.rrd_exec("create", RRD_CREATE % rrd):
|
||||
if args.debug:
|
||||
print "Could not create RRD database."
|
||||
return False
|
||||
return True
|
||||
def rrd_exec(self, function, rrd_cmd):
|
||||
cmd = "%s %s %s" % (RRDTOOL, function, rrd_cmd)
|
||||
if args.debug:
|
||||
print "RRD_EXEC (%s)" % cmd
|
||||
if os.system(cmd) != 0:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
class PingThread(threading.Thread):
|
||||
""" Threaded ping """
|
||||
def __init__(self, host_queue, rrd_service):
|
||||
threading.Thread.__init__(self)
|
||||
self.queue = queue
|
||||
self.rrd_service = rrd_service
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
host = self.queue.get()
|
||||
self.do_ping_host(host)
|
||||
self.queue.task_done()
|
||||
|
||||
def do_ping_host(self, host):
|
||||
interfaces = host.interface_set.all()
|
||||
for interface in interfaces:
|
||||
self.do_ping_interface(interface, host)
|
||||
|
||||
def do_ping_interface(self, interface, host):
|
||||
res = self.do_ping_ipaddr(interface.ip4address.address)
|
||||
if res == None:
|
||||
if args.debug:
|
||||
print "%s (%s/%s) : fail " % (host, \
|
||||
interface.name, interface.ip4address.address)
|
||||
return
|
||||
if args.debug:
|
||||
print "%s (%s/%s) : %s" % (host, interface.name, \
|
||||
interface.ip4address.address, res)
|
||||
self.rrd_service.log(host,interface, res[1])
|
||||
|
||||
def do_ping_ipaddr(self, ipaddr):
|
||||
status, output = getstatusoutput(PING_CMD % (args.num_pings,ipaddr))
|
||||
if status == 0 and len(output) > 0:
|
||||
return output.split("/")
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def show_host_types():
|
||||
types = HostType.objects.all()
|
||||
for t in types:
|
||||
print "%s (%s)" % (t.host_type, t.description)
|
||||
|
||||
if args.show_types:
|
||||
show_host_types()
|
||||
sys.exit(0)
|
||||
|
||||
if args.type == "all":
|
||||
hosts = Host.objects.all()
|
||||
else:
|
||||
hosts = Host.objects.filter(host_type__host_type = args.type)
|
||||
|
||||
rrd_service = RrdService()
|
||||
|
||||
# start num_threads threads for pinging
|
||||
for i in xrange(args.num_threads):
|
||||
t = PingThread(queue, rrd_service)
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
if args.debug:
|
||||
print "Pinging %d hosts using %d threads." % (len(hosts), args.num_threads)
|
||||
|
||||
# Add all hosts to the queue. Ping threads
|
||||
# will pick hosts from this queue.
|
||||
for host in hosts:
|
||||
queue.put(host)
|
||||
|
||||
# Wait for all threads to finish, eg. queue is empty.
|
||||
queue.join()
|
||||
|
||||
sys.exit(0)
|
||||
-567
@@ -1,567 +0,0 @@
|
||||
/*!
|
||||
* Bootstrap Responsive v2.0.0
|
||||
*
|
||||
* Copyright 2012 Twitter, Inc
|
||||
* Licensed under the Apache License v2.0
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Designed and built with all the love in the world @twitter by @mdo and @fat.
|
||||
*/
|
||||
.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.nav-collapse {
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
}
|
||||
.page-header h1 small {
|
||||
display: block;
|
||||
line-height: 18px;
|
||||
}
|
||||
input[class*="span"],
|
||||
select[class*="span"],
|
||||
textarea[class*="span"],
|
||||
.uneditable-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
/* Make inputs at least the height of their button counterpart */
|
||||
|
||||
/* Makes inputs behave like true block-level elements */
|
||||
|
||||
-webkit-box-sizing: border-box;
|
||||
/* Older Webkit */
|
||||
|
||||
-moz-box-sizing: border-box;
|
||||
/* Older FF */
|
||||
|
||||
-ms-box-sizing: border-box;
|
||||
/* IE8 */
|
||||
|
||||
box-sizing: border-box;
|
||||
/* CSS3 spec*/
|
||||
|
||||
}
|
||||
.input-prepend input[class*="span"], .input-append input[class*="span"] {
|
||||
width: auto;
|
||||
}
|
||||
input[type="checkbox"], input[type="radio"] {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.form-horizontal .control-group > label {
|
||||
float: none;
|
||||
width: auto;
|
||||
padding-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.form-horizontal .controls {
|
||||
margin-left: 0;
|
||||
}
|
||||
.form-horizontal .control-list {
|
||||
padding-top: 0;
|
||||
}
|
||||
.form-horizontal .form-actions {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.modal {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.modal.fade.in {
|
||||
top: auto;
|
||||
}
|
||||
.modal-header .close {
|
||||
padding: 10px;
|
||||
margin: -10px;
|
||||
}
|
||||
.carousel-caption {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
width: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.row-fluid {
|
||||
width: 100%;
|
||||
}
|
||||
.row {
|
||||
margin-left: 0;
|
||||
}
|
||||
.row > [class*="span"], .row-fluid > [class*="span"] {
|
||||
float: none;
|
||||
display: block;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) and (max-width: 980px) {
|
||||
.row {
|
||||
margin-left: -20px;
|
||||
*zoom: 1;
|
||||
}
|
||||
.row:before, .row:after {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
.row:after {
|
||||
clear: both;
|
||||
}
|
||||
[class*="span"] {
|
||||
float: left;
|
||||
margin-left: 20px;
|
||||
}
|
||||
.span1 {
|
||||
width: 42px;
|
||||
}
|
||||
.span2 {
|
||||
width: 104px;
|
||||
}
|
||||
.span3 {
|
||||
width: 166px;
|
||||
}
|
||||
.span4 {
|
||||
width: 228px;
|
||||
}
|
||||
.span5 {
|
||||
width: 290px;
|
||||
}
|
||||
.span6 {
|
||||
width: 352px;
|
||||
}
|
||||
.span7 {
|
||||
width: 414px;
|
||||
}
|
||||
.span8 {
|
||||
width: 476px;
|
||||
}
|
||||
.span9 {
|
||||
width: 538px;
|
||||
}
|
||||
.span10 {
|
||||
width: 600px;
|
||||
}
|
||||
.span11 {
|
||||
width: 662px;
|
||||
}
|
||||
.span12, .container {
|
||||
width: 724px;
|
||||
}
|
||||
.offset1 {
|
||||
margin-left: 82px;
|
||||
}
|
||||
.offset2 {
|
||||
margin-left: 144px;
|
||||
}
|
||||
.offset3 {
|
||||
margin-left: 206px;
|
||||
}
|
||||
.offset4 {
|
||||
margin-left: 268px;
|
||||
}
|
||||
.offset5 {
|
||||
margin-left: 330px;
|
||||
}
|
||||
.offset6 {
|
||||
margin-left: 392px;
|
||||
}
|
||||
.offset7 {
|
||||
margin-left: 454px;
|
||||
}
|
||||
.offset8 {
|
||||
margin-left: 516px;
|
||||
}
|
||||
.offset9 {
|
||||
margin-left: 578px;
|
||||
}
|
||||
.offset10 {
|
||||
margin-left: 640px;
|
||||
}
|
||||
.offset11 {
|
||||
margin-left: 702px;
|
||||
}
|
||||
.row-fluid {
|
||||
width: 100%;
|
||||
*zoom: 1;
|
||||
}
|
||||
.row-fluid:before, .row-fluid:after {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
.row-fluid:after {
|
||||
clear: both;
|
||||
}
|
||||
.row-fluid > [class*="span"] {
|
||||
float: left;
|
||||
margin-left: 2.762430939%;
|
||||
}
|
||||
.row-fluid > [class*="span"]:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.row-fluid .span1 {
|
||||
width: 5.801104972%;
|
||||
}
|
||||
.row-fluid .span2 {
|
||||
width: 14.364640883%;
|
||||
}
|
||||
.row-fluid .span3 {
|
||||
width: 22.928176794%;
|
||||
}
|
||||
.row-fluid .span4 {
|
||||
width: 31.491712705%;
|
||||
}
|
||||
.row-fluid .span5 {
|
||||
width: 40.055248616%;
|
||||
}
|
||||
.row-fluid .span6 {
|
||||
width: 48.618784527%;
|
||||
}
|
||||
.row-fluid .span7 {
|
||||
width: 57.182320438000005%;
|
||||
}
|
||||
.row-fluid .span8 {
|
||||
width: 65.74585634900001%;
|
||||
}
|
||||
.row-fluid .span9 {
|
||||
width: 74.30939226%;
|
||||
}
|
||||
.row-fluid .span10 {
|
||||
width: 82.87292817100001%;
|
||||
}
|
||||
.row-fluid .span11 {
|
||||
width: 91.436464082%;
|
||||
}
|
||||
.row-fluid .span12 {
|
||||
width: 99.999999993%;
|
||||
}
|
||||
input.span1, textarea.span1, .uneditable-input.span1 {
|
||||
width: 32px;
|
||||
}
|
||||
input.span2, textarea.span2, .uneditable-input.span2 {
|
||||
width: 94px;
|
||||
}
|
||||
input.span3, textarea.span3, .uneditable-input.span3 {
|
||||
width: 156px;
|
||||
}
|
||||
input.span4, textarea.span4, .uneditable-input.span4 {
|
||||
width: 218px;
|
||||
}
|
||||
input.span5, textarea.span5, .uneditable-input.span5 {
|
||||
width: 280px;
|
||||
}
|
||||
input.span6, textarea.span6, .uneditable-input.span6 {
|
||||
width: 342px;
|
||||
}
|
||||
input.span7, textarea.span7, .uneditable-input.span7 {
|
||||
width: 404px;
|
||||
}
|
||||
input.span8, textarea.span8, .uneditable-input.span8 {
|
||||
width: 466px;
|
||||
}
|
||||
input.span9, textarea.span9, .uneditable-input.span9 {
|
||||
width: 528px;
|
||||
}
|
||||
input.span10, textarea.span10, .uneditable-input.span10 {
|
||||
width: 590px;
|
||||
}
|
||||
input.span11, textarea.span11, .uneditable-input.span11 {
|
||||
width: 652px;
|
||||
}
|
||||
input.span12, textarea.span12, .uneditable-input.span12 {
|
||||
width: 714px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
body {
|
||||
padding-top: 0;
|
||||
}
|
||||
.navbar-fixed-top {
|
||||
position: static;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.navbar-fixed-top .navbar-inner {
|
||||
padding: 5px;
|
||||
}
|
||||
.navbar .container {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.navbar .brand {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
margin: 0 0 0 -5px;
|
||||
}
|
||||
.navbar .nav-collapse {
|
||||
clear: left;
|
||||
}
|
||||
.navbar .nav {
|
||||
float: none;
|
||||
margin: 0 0 9px;
|
||||
}
|
||||
.navbar .nav > li {
|
||||
float: none;
|
||||
}
|
||||
.navbar .nav > li > a {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.navbar .nav > .divider-vertical {
|
||||
display: none;
|
||||
}
|
||||
.navbar .nav > li > a, .navbar .dropdown-menu a {
|
||||
padding: 6px 15px;
|
||||
font-weight: bold;
|
||||
color: #999999;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.navbar .dropdown-menu li + li a {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.navbar .nav > li > a:hover, .navbar .dropdown-menu a:hover {
|
||||
background-color: #222222;
|
||||
}
|
||||
.navbar .dropdown-menu {
|
||||
position: static;
|
||||
top: auto;
|
||||
left: auto;
|
||||
float: none;
|
||||
display: block;
|
||||
max-width: none;
|
||||
margin: 0 15px;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
-webkit-border-radius: 0;
|
||||
-moz-border-radius: 0;
|
||||
border-radius: 0;
|
||||
-webkit-box-shadow: none;
|
||||
-moz-box-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.navbar .dropdown-menu:before, .navbar .dropdown-menu:after {
|
||||
display: none;
|
||||
}
|
||||
.navbar .dropdown-menu .divider {
|
||||
display: none;
|
||||
}
|
||||
.navbar-form, .navbar-search {
|
||||
float: none;
|
||||
padding: 9px 15px;
|
||||
margin: 9px 0;
|
||||
border-top: 1px solid #222222;
|
||||
border-bottom: 1px solid #222222;
|
||||
-webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
-moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.navbar .nav.pull-right {
|
||||
float: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
.navbar-static .navbar-inner {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.btn-navbar {
|
||||
display: block;
|
||||
}
|
||||
.nav-collapse {
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@media (min-width: 980px) {
|
||||
.nav-collapse.collapse {
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.row {
|
||||
margin-left: -30px;
|
||||
*zoom: 1;
|
||||
}
|
||||
.row:before, .row:after {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
.row:after {
|
||||
clear: both;
|
||||
}
|
||||
[class*="span"] {
|
||||
float: left;
|
||||
margin-left: 30px;
|
||||
}
|
||||
.span1 {
|
||||
width: 70px;
|
||||
}
|
||||
.span2 {
|
||||
width: 170px;
|
||||
}
|
||||
.span3 {
|
||||
width: 270px;
|
||||
}
|
||||
.span4 {
|
||||
width: 370px;
|
||||
}
|
||||
.span5 {
|
||||
width: 470px;
|
||||
}
|
||||
.span6 {
|
||||
width: 570px;
|
||||
}
|
||||
.span7 {
|
||||
width: 670px;
|
||||
}
|
||||
.span8 {
|
||||
width: 770px;
|
||||
}
|
||||
.span9 {
|
||||
width: 870px;
|
||||
}
|
||||
.span10 {
|
||||
width: 970px;
|
||||
}
|
||||
.span11 {
|
||||
width: 1070px;
|
||||
}
|
||||
.span12, .container {
|
||||
width: 1170px;
|
||||
}
|
||||
.offset1 {
|
||||
margin-left: 130px;
|
||||
}
|
||||
.offset2 {
|
||||
margin-left: 230px;
|
||||
}
|
||||
.offset3 {
|
||||
margin-left: 330px;
|
||||
}
|
||||
.offset4 {
|
||||
margin-left: 430px;
|
||||
}
|
||||
.offset5 {
|
||||
margin-left: 530px;
|
||||
}
|
||||
.offset6 {
|
||||
margin-left: 630px;
|
||||
}
|
||||
.offset7 {
|
||||
margin-left: 730px;
|
||||
}
|
||||
.offset8 {
|
||||
margin-left: 830px;
|
||||
}
|
||||
.offset9 {
|
||||
margin-left: 930px;
|
||||
}
|
||||
.offset10 {
|
||||
margin-left: 1030px;
|
||||
}
|
||||
.offset11 {
|
||||
margin-left: 1130px;
|
||||
}
|
||||
.row-fluid {
|
||||
width: 100%;
|
||||
*zoom: 1;
|
||||
}
|
||||
.row-fluid:before, .row-fluid:after {
|
||||
display: table;
|
||||
content: "";
|
||||
}
|
||||
.row-fluid:after {
|
||||
clear: both;
|
||||
}
|
||||
.row-fluid > [class*="span"] {
|
||||
float: left;
|
||||
margin-left: 2.564102564%;
|
||||
}
|
||||
.row-fluid > [class*="span"]:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.row-fluid .span1 {
|
||||
width: 5.982905983%;
|
||||
}
|
||||
.row-fluid .span2 {
|
||||
width: 14.529914530000001%;
|
||||
}
|
||||
.row-fluid .span3 {
|
||||
width: 23.076923077%;
|
||||
}
|
||||
.row-fluid .span4 {
|
||||
width: 31.623931624%;
|
||||
}
|
||||
.row-fluid .span5 {
|
||||
width: 40.170940171000005%;
|
||||
}
|
||||
.row-fluid .span6 {
|
||||
width: 48.717948718%;
|
||||
}
|
||||
.row-fluid .span7 {
|
||||
width: 57.264957265%;
|
||||
}
|
||||
.row-fluid .span8 {
|
||||
width: 65.81196581200001%;
|
||||
}
|
||||
.row-fluid .span9 {
|
||||
width: 74.358974359%;
|
||||
}
|
||||
.row-fluid .span10 {
|
||||
width: 82.905982906%;
|
||||
}
|
||||
.row-fluid .span11 {
|
||||
width: 91.45299145300001%;
|
||||
}
|
||||
.row-fluid .span12 {
|
||||
width: 100%;
|
||||
}
|
||||
input.span1, textarea.span1, .uneditable-input.span1 {
|
||||
width: 60px;
|
||||
}
|
||||
input.span2, textarea.span2, .uneditable-input.span2 {
|
||||
width: 160px;
|
||||
}
|
||||
input.span3, textarea.span3, .uneditable-input.span3 {
|
||||
width: 260px;
|
||||
}
|
||||
input.span4, textarea.span4, .uneditable-input.span4 {
|
||||
width: 360px;
|
||||
}
|
||||
input.span5, textarea.span5, .uneditable-input.span5 {
|
||||
width: 460px;
|
||||
}
|
||||
input.span6, textarea.span6, .uneditable-input.span6 {
|
||||
width: 560px;
|
||||
}
|
||||
input.span7, textarea.span7, .uneditable-input.span7 {
|
||||
width: 660px;
|
||||
}
|
||||
input.span8, textarea.span8, .uneditable-input.span8 {
|
||||
width: 760px;
|
||||
}
|
||||
input.span9, textarea.span9, .uneditable-input.span9 {
|
||||
width: 860px;
|
||||
}
|
||||
input.span10, textarea.span10, .uneditable-input.span10 {
|
||||
width: 960px;
|
||||
}
|
||||
input.span11, textarea.span11, .uneditable-input.span11 {
|
||||
width: 1060px;
|
||||
}
|
||||
input.span12, textarea.span12, .uneditable-input.span12 {
|
||||
width: 1160px;
|
||||
}
|
||||
.thumbnails {
|
||||
margin-left: -30px;
|
||||
}
|
||||
.thumbnails > li {
|
||||
margin-left: 30px;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
.hidden{display:none;visibility:hidden;}
|
||||
@media (max-width:480px){.nav-collapse{-webkit-transform:translate3d(0, 0, 0);} .page-header h1 small{display:block;line-height:18px;} input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{display:block;width:100%;height:28px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-ms-box-sizing:border-box;box-sizing:border-box;} .input-prepend input[class*="span"],.input-append input[class*="span"]{width:auto;} input[type="checkbox"],input[type="radio"]{border:1px solid #ccc;} .form-horizontal .control-group>label{float:none;width:auto;padding-top:0;text-align:left;} .form-horizontal .controls{margin-left:0;} .form-horizontal .control-list{padding-top:0;} .form-horizontal .form-actions{padding-left:10px;padding-right:10px;} .modal{position:absolute;top:10px;left:10px;right:10px;width:auto;margin:0;}.modal.fade.in{top:auto;} .modal-header .close{padding:10px;margin:-10px;} .carousel-caption{position:static;}}@media (max-width:768px){.container{width:auto;padding:0 20px;} .row-fluid{width:100%;} .row{margin-left:0;} .row>[class*="span"],.row-fluid>[class*="span"]{float:none;display:block;width:auto;margin:0;}}@media (min-width:768px) and (max-width:980px){.row{margin-left:-20px;*zoom:1;}.row:before,.row:after{display:table;content:"";} .row:after{clear:both;} [class*="span"]{float:left;margin-left:20px;} .span1{width:42px;} .span2{width:104px;} .span3{width:166px;} .span4{width:228px;} .span5{width:290px;} .span6{width:352px;} .span7{width:414px;} .span8{width:476px;} .span9{width:538px;} .span10{width:600px;} .span11{width:662px;} .span12,.container{width:724px;} .offset1{margin-left:82px;} .offset2{margin-left:144px;} .offset3{margin-left:206px;} .offset4{margin-left:268px;} .offset5{margin-left:330px;} .offset6{margin-left:392px;} .offset7{margin-left:454px;} .offset8{margin-left:516px;} .offset9{margin-left:578px;} .offset10{margin-left:640px;} .offset11{margin-left:702px;} .row-fluid{width:100%;*zoom:1;}.row-fluid:before,.row-fluid:after{display:table;content:"";} .row-fluid:after{clear:both;} .row-fluid>[class*="span"]{float:left;margin-left:2.762430939%;} .row-fluid>[class*="span"]:first-child{margin-left:0;} .row-fluid .span1{width:5.801104972%;} .row-fluid .span2{width:14.364640883%;} .row-fluid .span3{width:22.928176794%;} .row-fluid .span4{width:31.491712705%;} .row-fluid .span5{width:40.055248616%;} .row-fluid .span6{width:48.618784527%;} .row-fluid .span7{width:57.182320438000005%;} .row-fluid .span8{width:65.74585634900001%;} .row-fluid .span9{width:74.30939226%;} .row-fluid .span10{width:82.87292817100001%;} .row-fluid .span11{width:91.436464082%;} .row-fluid .span12{width:99.999999993%;} input.span1,textarea.span1,.uneditable-input.span1{width:32px;} input.span2,textarea.span2,.uneditable-input.span2{width:94px;} input.span3,textarea.span3,.uneditable-input.span3{width:156px;} input.span4,textarea.span4,.uneditable-input.span4{width:218px;} input.span5,textarea.span5,.uneditable-input.span5{width:280px;} input.span6,textarea.span6,.uneditable-input.span6{width:342px;} input.span7,textarea.span7,.uneditable-input.span7{width:404px;} input.span8,textarea.span8,.uneditable-input.span8{width:466px;} input.span9,textarea.span9,.uneditable-input.span9{width:528px;} input.span10,textarea.span10,.uneditable-input.span10{width:590px;} input.span11,textarea.span11,.uneditable-input.span11{width:652px;} input.span12,textarea.span12,.uneditable-input.span12{width:714px;}}@media (max-width:980px){body{padding-top:0;} .navbar-fixed-top{position:static;margin-bottom:18px;} .navbar-fixed-top .navbar-inner{padding:5px;} .navbar .container{width:auto;padding:0;} .navbar .brand{padding-left:10px;padding-right:10px;margin:0 0 0 -5px;} .navbar .nav-collapse{clear:left;} .navbar .nav{float:none;margin:0 0 9px;} .navbar .nav>li{float:none;} .navbar .nav>li>a{margin-bottom:2px;} .navbar .nav>.divider-vertical{display:none;} .navbar .nav>li>a,.navbar .dropdown-menu a{padding:6px 15px;font-weight:bold;color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;} .navbar .dropdown-menu li+li a{margin-bottom:2px;} .navbar .nav>li>a:hover,.navbar .dropdown-menu a:hover{background-color:#222222;} .navbar .dropdown-menu{position:static;top:auto;left:auto;float:none;display:block;max-width:none;margin:0 15px;padding:0;background-color:transparent;border:none;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;} .navbar .dropdown-menu:before,.navbar .dropdown-menu:after{display:none;} .navbar .dropdown-menu .divider{display:none;} .navbar-form,.navbar-search{float:none;padding:9px 15px;margin:9px 0;border-top:1px solid #222222;border-bottom:1px solid #222222;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.1);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 25Line truncated
|
||||
Vendored
-3365
File diff suppressed because it is too large.
Load diff
-610
@@ -1,610 +0,0 @@
|
||||
article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block;}
|
||||
audio,canvas,video{display:inline-block;*display:inline;*zoom:1;}
|
||||
audio:not([controls]){display:none;}
|
||||
html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
|
||||
a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
|
||||
a:hover,a:active{outline:0;}
|
||||
sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline;}
|
||||
sup{top:-0.5em;}
|
||||
sub{bottom:-0.25em;}
|
||||
img{max-width:100%;height:auto;border:0;-ms-interpolation-mode:bicubic;}
|
||||
button,input,select,textarea{margin:0;font-size:100%;vertical-align:middle;}
|
||||
button,input{*overflow:visible;line-height:normal;}
|
||||
button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0;}
|
||||
button,input[type="button"],input[type="reset"],input[type="submit"]{cursor:pointer;-webkit-appearance:button;}
|
||||
input[type="search"]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;}
|
||||
input[type="search"]::-webkit-search-decoration,input[type="search"]::-webkit-search-cancel-button{-webkit-appearance:none;}
|
||||
textarea{overflow:auto;vertical-align:top;}
|
||||
body{margin:0;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;line-height:18px;color:#333333;background-color:#ffffff;}
|
||||
a{color:#0088cc;text-decoration:none;}
|
||||
a:hover{color:#005580;text-decoration:underline;}
|
||||
.row{margin-left:-20px;*zoom:1;}.row:before,.row:after{display:table;content:"";}
|
||||
.row:after{clear:both;}
|
||||
[class*="span"]{float:left;margin-left:20px;}
|
||||
.span1{width:60px;}
|
||||
.span2{width:140px;}
|
||||
.span3{width:220px;}
|
||||
.span4{width:300px;}
|
||||
.span5{width:380px;}
|
||||
.span6{width:460px;}
|
||||
.span7{width:540px;}
|
||||
.span8{width:620px;}
|
||||
.span9{width:700px;}
|
||||
.span10{width:780px;}
|
||||
.span11{width:860px;}
|
||||
.span12,.container{width:940px;}
|
||||
.offset1{margin-left:100px;}
|
||||
.offset2{margin-left:180px;}
|
||||
.offset3{margin-left:260px;}
|
||||
.offset4{margin-left:340px;}
|
||||
.offset5{margin-left:420px;}
|
||||
.offset6{margin-left:500px;}
|
||||
.offset7{margin-left:580px;}
|
||||
.offset8{margin-left:660px;}
|
||||
.offset9{margin-left:740px;}
|
||||
.offset10{margin-left:820px;}
|
||||
.offset11{margin-left:900px;}
|
||||
.row-fluid{width:100%;*zoom:1;}.row-fluid:before,.row-fluid:after{display:table;content:"";}
|
||||
.row-fluid:after{clear:both;}
|
||||
.row-fluid>[class*="span"]{float:left;margin-left:2.127659574%;}
|
||||
.row-fluid>[class*="span"]:first-child{margin-left:0;}
|
||||
.row-fluid .span1{width:6.382978723%;}
|
||||
.row-fluid .span2{width:14.89361702%;}
|
||||
.row-fluid .span3{width:23.404255317%;}
|
||||
.row-fluid .span4{width:31.914893614%;}
|
||||
.row-fluid .span5{width:40.425531911%;}
|
||||
.row-fluid .span6{width:48.93617020799999%;}
|
||||
.row-fluid .span7{width:57.446808505%;}
|
||||
.row-fluid .span8{width:65.95744680199999%;}
|
||||
.row-fluid .span9{width:74.468085099%;}
|
||||
.row-fluid .span10{width:82.97872339599999%;}
|
||||
.row-fluid .span11{width:91.489361693%;}
|
||||
.row-fluid .span12{width:99.99999998999999%;}
|
||||
.container{width:940px;margin-left:auto;margin-right:auto;*zoom:1;}.container:before,.container:after{display:table;content:"";}
|
||||
.container:after{clear:both;}
|
||||
.container-fluid{padding-left:20px;padding-right:20px;*zoom:1;}.container-fluid:before,.container-fluid:after{display:table;content:"";}
|
||||
.container-fluid:after{clear:both;}
|
||||
p{margin:0 0 9px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;line-height:18px;}p small{font-size:11px;color:#999999;}
|
||||
.lead{margin-bottom:18px;font-size:20px;font-weight:200;line-height:27px;}
|
||||
h1,h2,h3,h4,h5,h6{margin:0;font-weight:bold;color:#333333;text-rendering:optimizelegibility;}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{font-weight:normal;color:#999999;}
|
||||
h1{font-size:30px;line-height:36px;}h1 small{font-size:18px;}
|
||||
h2{font-size:24px;line-height:36px;}h2 small{font-size:18px;}
|
||||
h3{line-height:27px;font-size:18px;}h3 small{font-size:14px;}
|
||||
h4,h5,h6{line-height:18px;}
|
||||
h4{font-size:14px;}h4 small{font-size:12px;}
|
||||
h5{font-size:12px;}
|
||||
h6{font-size:11px;color:#999999;text-transform:uppercase;}
|
||||
.page-header{padding-bottom:17px;margin:18px 0;border-bottom:1px solid #eeeeee;}
|
||||
.page-header h1{line-height:1;}
|
||||
ul,ol{padding:0;margin:0 0 9px 25px;}
|
||||
ul ul,ul ol,ol ol,ol ul{margin-bottom:0;}
|
||||
ul{list-style:disc;}
|
||||
ol{list-style:decimal;}
|
||||
li{line-height:18px;}
|
||||
ul.unstyled{margin-left:0;list-style:none;}
|
||||
dl{margin-bottom:18px;}
|
||||
dt,dd{line-height:18px;}
|
||||
dt{font-weight:bold;}
|
||||
dd{margin-left:9px;}
|
||||
hr{margin:18px 0;border:0;border-top:1px solid #e5e5e5;border-bottom:1px solid #ffffff;}
|
||||
strong{font-weight:bold;}
|
||||
em{font-style:italic;}
|
||||
.muted{color:#999999;}
|
||||
abbr{font-size:90%;text-transform:uppercase;border-bottom:1px dotted #ddd;cursor:help;}
|
||||
blockquote{padding:0 0 0 15px;margin:0 0 18px;border-left:5px solid #eeeeee;}blockquote p{margin-bottom:0;font-size:16px;font-weight:300;line-height:22.5px;}
|
||||
blockquote small{display:block;line-height:18px;color:#999999;}blockquote small:before{content:'\2014 \00A0';}
|
||||
blockquote.pull-right{float:right;padding-left:0;padding-right:15px;border-left:0;border-right:5px solid #eeeeee;}blockquote.pull-right p,blockquote.pull-right small{text-align:right;}
|
||||
q:before,q:after,blockquote:before,blockquote:after{content:"";}
|
||||
address{display:block;margin-bottom:18px;line-height:18px;font-style:normal;}
|
||||
small{font-size:100%;}
|
||||
cite{font-style:normal;}
|
||||
code,pre{padding:0 3px 2px;font-family:Menlo,Monaco,"Courier New",monospace;font-size:12px;color:#333333;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
|
||||
code{padding:3px 4px;color:#d14;background-color:#f7f7f9;border:1px solid #e1e1e8;}
|
||||
pre{display:block;padding:8.5px;margin:0 0 9px;font-size:12px;line-height:18px;background-color:#f5f5f5;border:1px solid #ccc;border:1px solid rgba(0, 0, 0, 0.15);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;white-space:pre;white-space:pre-wrap;word-break:break-all;}pre.prettyprint{margin-bottom:18px;}
|
||||
pre code{padding:0;background-color:transparent;}
|
||||
form{margin:0 0 18px;}
|
||||
fieldset{padding:0;margin:0;border:0;}
|
||||
legend{display:block;width:100%;padding:0;margin-bottom:27px;font-size:19.5px;line-height:36px;color:#333333;border:0;border-bottom:1px solid #eee;}
|
||||
label,input,button,select,textarea{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:18px;}
|
||||
label{display:block;margin-bottom:5px;color:#333333;}
|
||||
input,textarea,select,.uneditable-input{display:inline-block;width:210px;height:18px;padding:4px;margin-bottom:9px;font-size:13px;line-height:18px;color:#555555;border:1px solid #ccc;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
|
||||
.uneditable-textarea{width:auto;height:auto;}
|
||||
label input,label textarea,label select{display:block;}
|
||||
input[type="image"],input[type="checkbox"],input[type="radio"]{width:auto;height:auto;padding:0;margin:3px 0;*margin-top:0;line-height:normal;border:0;cursor:pointer;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
|
||||
input[type="file"]{padding:initial;line-height:initial;border:initial;background-color:#ffffff;background-color:initial;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
|
||||
input[type="button"],input[type="reset"],input[type="submit"]{width:auto;height:auto;}
|
||||
select,input[type="file"]{height:28px;*margin-top:4px;line-height:28px;}
|
||||
select{width:220px;background-color:#ffffff;}
|
||||
select[multiple],select[size]{height:auto;}
|
||||
input[type="image"]{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
|
||||
textarea{height:auto;}
|
||||
input[type="hidden"]{display:none;}
|
||||
.radio,.checkbox{padding-left:18px;}
|
||||
.radio input[type="radio"],.checkbox input[type="checkbox"]{float:left;margin-left:-18px;}
|
||||
.controls>.radio:first-child,.controls>.checkbox:first-child{padding-top:5px;}
|
||||
.radio.inline,.checkbox.inline{display:inline-block;margin-bottom:0;vertical-align:middle;}
|
||||
.radio.inline+.radio.inline,.checkbox.inline+.checkbox.inline{margin-left:10px;}
|
||||
.controls>.radio.inline:first-child,.controls>.checkbox.inline:first-child{padding-top:0;}
|
||||
input,textarea{-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075);-webkit-transition:border linear 0.2s,box-shadow linear 0.2s;-moz-transition:border linear 0.2s,box-shadow linear 0.2s;-ms-transition:border linear 0.2s,box-shadow linear 0.2s;-o-transition:border linear 0.2s,box-shadow linear 0.2s;transition:border linear 0.2s,box-shadow linear 0.2s;}
|
||||
input:focus,textarea:focus{border-color:rgba(82, 168, 236, 0.8);-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.075),0 0 8px rgba(82, 168, 236, 0.6);outline:0;outline:thin dotted \9;}
|
||||
input[type="file"]:focus,input[type="checkbox"]:focus,select:focus{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
|
||||
.input-mini{width:60px;}
|
||||
.input-small{width:90px;}
|
||||
.input-medium{width:150px;}
|
||||
.input-large{width:210px;}
|
||||
.input-xlarge{width:270px;}
|
||||
.input-xxlarge{width:530px;}
|
||||
input[class*="span"],select[class*="span"],textarea[class*="span"],.uneditable-input{float:none;margin-left:0;}
|
||||
input.span1,textarea.span1,.uneditable-input.span1{width:50px;}
|
||||
input.span2,textarea.span2,.uneditable-input.span2{width:130px;}
|
||||
input.span3,textarea.span3,.uneditable-input.span3{width:210px;}
|
||||
input.span4,textarea.span4,.uneditable-input.span4{width:290px;}
|
||||
input.span5,textarea.span5,.uneditable-input.span5{width:370px;}
|
||||
input.span6,textarea.span6,.uneditable-input.span6{width:450px;}
|
||||
input.span7,textarea.span7,.uneditable-input.span7{width:530px;}
|
||||
input.span8,textarea.span8,.uneditable-input.span8{width:610px;}
|
||||
input.span9,textarea.span9,.uneditable-input.span9{width:690px;}
|
||||
input.span10,textarea.span10,.uneditable-input.span10{width:770px;}
|
||||
input.span11,textarea.span11,.uneditable-input.span11{width:850px;}
|
||||
input.span12,textarea.span12,.uneditable-input.span12{width:930px;}
|
||||
input[disabled],select[disabled],textarea[disabled],input[readonly],select[readonly],textarea[readonly]{background-color:#f5f5f5;border-color:#ddd;cursor:not-allowed;}
|
||||
.control-group.warning>label,.control-group.warning .help-block,.control-group.warning .help-inline{color:#c09853;}
|
||||
.control-group.warning input,.control-group.warning select,.control-group.warning textarea{color:#c09853;border-color:#c09853;}.control-group.warning input:focus,.control-group.warning select:focus,.control-group.warning textarea:focus{border-color:#a47e3c;-webkit-box-shadow:0 0 6px #dbc59e;-moz-box-shadow:0 0 6px #dbc59e;box-shadow:0 0 6px #dbc59e;}
|
||||
.control-group.warning .input-prepend .add-on,.control-group.warning .input-append .add-on{color:#c09853;background-color:#fcf8e3;border-color:#c09853;}
|
||||
.control-group.error>label,.control-group.error .help-block,.control-group.error .help-inline{color:#b94a48;}
|
||||
.control-group.error input,.control-group.error select,.control-group.error textarea{color:#b94a48;border-color:#b94a48;}.control-group.error input:focus,.control-group.error select:focus,.control-group.error textarea:focus{border-color:#953b39;-webkit-box-shadow:0 0 6px #d59392;-moz-box-shadow:0 0 6px #d59392;box-shadow:0 0 6px #d59392;}
|
||||
.control-group.error .input-prepend .add-on,.control-group.error .input-append .add-on{color:#b94a48;background-color:#f2dede;border-color:#b94a48;}
|
||||
.control-group.success>label,.control-group.success .help-block,.control-group.success .help-inline{color:#468847;}
|
||||
.control-group.success input,.control-group.success select,.control-group.success textarea{color:#468847;border-color:#468847;}.control-group.success input:focus,.control-group.success select:focus,.control-group.success textarea:focus{border-color:#356635;-webkit-box-shadow:0 0 6px #7aba7b;-moz-box-shadow:0 0 6px #7aba7b;box-shadow:0 0 6px #7aba7b;}
|
||||
.control-group.success .input-prepend .add-on,.control-group.success .input-append .add-on{color:#468847;background-color:#dff0d8;border-color:#468847;}
|
||||
input:focus:required:invalid,textarea:focus:required:invalid,select:focus:required:invalid{color:#b94a48;border-color:#ee5f5b;}input:focus:required:invalid:focus,textarea:focus:required:invalid:focus,select:focus:required:invalid:focus{border-color:#e9322d;-webkit-box-shadow:0 0 6px #f8b9b7;-moz-box-shadow:0 0 6px #f8b9b7;box-shadow:0 0 6px #f8b9b7;}
|
||||
.form-actions{padding:17px 20px 18px;margin-top:18px;margin-bottom:18px;background-color:#f5f5f5;border-top:1px solid #ddd;}
|
||||
.uneditable-input{display:block;background-color:#ffffff;border-color:#eee;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.025);cursor:not-allowed;}
|
||||
:-moz-placeholder{color:#999999;}
|
||||
::-webkit-input-placeholder{color:#999999;}
|
||||
.help-block{margin-top:5px;margin-bottom:0;color:#999999;}
|
||||
.help-inline{display:inline-block;*display:inline;*zoom:1;margin-bottom:9px;vertical-align:middle;padding-left:5px;}
|
||||
.input-prepend,.input-append{margin-bottom:5px;*zoom:1;}.input-prepend:before,.input-append:before,.input-prepend:after,.input-append:after{display:table;content:"";}
|
||||
.input-prepend:after,.input-append:after{clear:both;}
|
||||
.input-prepend input,.input-append input,.input-prepend .uneditable-input,.input-append .uneditable-input{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}.input-prepend input:focus,.input-append input:focus,.input-prepend .uneditable-input:focus,.input-append .uneditable-input:focus{position:relative;z-index:2;}
|
||||
.input-prepend .uneditable-input,.input-append .uneditable-input{border-left-color:#ccc;}
|
||||
.input-prepend .add-on,.input-append .add-on{float:left;display:block;width:auto;min-width:16px;height:18px;margin-right:-1px;padding:4px 5px;font-weight:normal;line-height:18px;color:#999999;text-align:center;text-shadow:0 1px 0 #ffffff;background-color:#f5f5f5;border:1px solid #ccc;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
|
||||
.input-prepend .active,.input-append .active{background-color:#a9dba9;border-color:#46a546;}
|
||||
.input-prepend .add-on{*margin-top:1px;}
|
||||
.input-append input,.input-append .uneditable-input{float:left;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
|
||||
.input-append .uneditable-input{border-right-color:#ccc;}
|
||||
.input-append .add-on{margin-right:0;margin-left:-1px;-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}
|
||||
.input-append input:first-child{*margin-left:-160px;}.input-append input:first-child+.add-on{*margin-left:-21px;}
|
||||
.search-query{padding-left:14px;padding-right:14px;margin-bottom:0;-webkit-border-radius:14px;-moz-border-radius:14px;border-radius:14px;}
|
||||
.form-search input,.form-inline input,.form-horizontal input,.form-search textarea,.form-inline textarea,.form-horizontal textarea,.form-search select,.form-inline select,.form-horizontal select,.form-search .help-inline,.form-inline .help-inline,.form-horizontal .help-inline,.form-search .uneditable-input,.form-inline .uneditable-input,.form-horizontal .uneditable-input{display:inline-block;margin-bottom:0;}
|
||||
.form-search label,.form-inline label,.form-search .input-append,.form-inline .input-append,.form-search .input-prepend,.form-inline .input-prepend{display:inline-block;}
|
||||
.form-search .input-append .add-on,.form-inline .input-prepend .add-on,.form-search .input-append .add-on,.form-inline .input-prepend .add-on{vertical-align:middle;}
|
||||
.control-group{margin-bottom:9px;}
|
||||
.form-horizontal legend+.control-group{margin-top:18px;-webkit-margin-top-collapse:separate;}
|
||||
.form-horizontal .control-group{margin-bottom:18px;*zoom:1;}.form-horizontal .control-group:before,.form-horizontal .control-group:after{display:table;content:"";}
|
||||
.form-horizontal .control-group:after{clear:both;}
|
||||
.form-horizontal .control-group>label{float:left;width:140px;padding-top:5px;text-align:right;}
|
||||
.form-horizontal .controls{margin-left:160px;}
|
||||
.form-horizontal .form-actions{padding-left:160px;}
|
||||
table{max-width:100%;border-collapse:collapse;border-spacing:0;}
|
||||
.table{width:100%;margin-bottom:18px;}.table th,.table td{padding:8px;line-height:18px;text-align:left;border-top:1px solid #ddd;}
|
||||
.table th{font-weight:bold;vertical-align:bottom;}
|
||||
.table td{vertical-align:top;}
|
||||
.table thead:first-child tr th,.table thead:first-child tr td{border-top:0;}
|
||||
.table tbody+tbody{border-top:2px solid #ddd;}
|
||||
.table-condensed th,.table-condensed td{padding:4px 5px;}
|
||||
.table-bordered{border:1px solid #ddd;border-collapse:separate;*border-collapse:collapsed;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}.table-bordered th+th,.table-bordered td+td,.table-bordered th+td,.table-bordered td+th{border-left:1px solid #ddd;}
|
||||
.table-bordered thead:first-child tr:first-child th,.table-bordered tbody:first-child tr:first-child th,.table-bordered tbody:first-child tr:first-child td{border-top:0;}
|
||||
.table-bordered thead:first-child tr:first-child th:first-child,.table-bordered tbody:first-child tr:first-child td:first-child{-webkit-border-radius:4px 0 0 0;-moz-border-radius:4px 0 0 0;border-radius:4px 0 0 0;}
|
||||
.table-bordered thead:first-child tr:first-child th:last-child,.table-bordered tbody:first-child tr:first-child td:last-child{-webkit-border-radius:0 4px 0 0;-moz-border-radius:0 4px 0 0;border-radius:0 4px 0 0;}
|
||||
.table-bordered thead:last-child tr:last-child th:first-child,.table-bordered tbody:last-child tr:last-child td:first-child{-webkit-border-radius:0 0 0 4px;-moz-border-radius:0 0 0 4px;border-radius:0 0 0 4px;}
|
||||
.table-bordered thead:last-child tr:last-child th:last-child,.table-bordered tbody:last-child tr:last-child td:last-child{-webkit-border-radius:0 0 4px 0;-moz-border-radius:0 0 4px 0;border-radius:0 0 4px 0;}
|
||||
.table-striped tbody tr:nth-child(odd) td,.table-striped tbody tr:nth-child(odd) th{background-color:#f9f9f9;}
|
||||
table .span1{float:none;width:44px;margin-left:0;}
|
||||
table .span2{float:none;width:124px;margin-left:0;}
|
||||
table .span3{float:none;width:204px;margin-left:0;}
|
||||
table .span4{float:none;width:284px;margin-left:0;}
|
||||
table .span5{float:none;width:364px;margin-left:0;}
|
||||
table .span6{float:none;width:444px;margin-left:0;}
|
||||
table .span7{float:none;width:524px;margin-left:0;}
|
||||
table .span8{float:none;width:604px;margin-left:0;}
|
||||
table .span9{float:none;width:684px;margin-left:0;}
|
||||
table .span10{float:none;width:764px;margin-left:0;}
|
||||
table .span11{float:none;width:844px;margin-left:0;}
|
||||
table .span12{float:none;width:924px;margin-left:0;}
|
||||
[class^="icon-"]{display:inline-block;width:14px;height:14px;vertical-align:text-top;background-image:url(../img/glyphicons-halflings.png);background-position:14px 14px;background-repeat:no-repeat;*margin-right:.3em;}[class^="icon-"]:last-child{*margin-left:0;}
|
||||
.icon-white{background-image:url(../img/glyphicons-halflings-white.png);}
|
||||
.icon-glass{background-position:0 0;}
|
||||
.icon-music{background-position:-24px 0;}
|
||||
.icon-search{background-position:-48px 0;}
|
||||
.icon-envelope{background-position:-72px 0;}
|
||||
.icon-heart{background-position:-96px 0;}
|
||||
.icon-star{background-position:-120px 0;}
|
||||
.icon-star-empty{background-position:-144px 0;}
|
||||
.icon-user{background-position:-168px 0;}
|
||||
.icon-film{background-position:-192px 0;}
|
||||
.icon-th-large{background-position:-216px 0;}
|
||||
.icon-th{background-position:-240px 0;}
|
||||
.icon-th-list{background-position:-264px 0;}
|
||||
.icon-ok{background-position:-288px 0;}
|
||||
.icon-remove{background-position:-312px 0;}
|
||||
.icon-zoom-in{background-position:-336px 0;}
|
||||
.icon-zoom-out{background-position:-360px 0;}
|
||||
.icon-off{background-position:-384px 0;}
|
||||
.icon-signal{background-position:-408px 0;}
|
||||
.icon-cog{background-position:-432px 0;}
|
||||
.icon-trash{background-position:-456px 0;}
|
||||
.icon-home{background-position:0 -24px;}
|
||||
.icon-file{background-position:-24px -24px;}
|
||||
.icon-time{background-position:-48px -24px;}
|
||||
.icon-road{background-position:-72px -24px;}
|
||||
.icon-download-alt{background-position:-96px -24px;}
|
||||
.icon-download{background-position:-120px -24px;}
|
||||
.icon-upload{background-position:-144px -24px;}
|
||||
.icon-inbox{background-position:-168px -24px;}
|
||||
.icon-play-circle{background-position:-192px -24px;}
|
||||
.icon-repeat{background-position:-216px -24px;}
|
||||
.icon-refresh{background-position:-240px -24px;}
|
||||
.icon-list-alt{background-position:-264px -24px;}
|
||||
.icon-lock{background-position:-287px -24px;}
|
||||
.icon-flag{background-position:-312px -24px;}
|
||||
.icon-headphones{background-position:-336px -24px;}
|
||||
.icon-volume-off{background-position:-360px -24px;}
|
||||
.icon-volume-down{background-position:-384px -24px;}
|
||||
.icon-volume-up{background-position:-408px -24px;}
|
||||
.icon-qrcode{background-position:-432px -24px;}
|
||||
.icon-barcode{background-position:-456px -24px;}
|
||||
.icon-tag{background-position:0 -48px;}
|
||||
.icon-tags{background-position:-25px -48px;}
|
||||
.icon-book{background-position:-48px -48px;}
|
||||
.icon-bookmark{background-position:-72px -48px;}
|
||||
.icon-print{background-position:-96px -48px;}
|
||||
.icon-camera{background-position:-120px -48px;}
|
||||
.icon-font{background-position:-144px -48px;}
|
||||
.icon-bold{background-position:-167px -48px;}
|
||||
.icon-italic{background-position:-192px -48px;}
|
||||
.icon-text-height{background-position:-216px -48px;}
|
||||
.icon-text-width{background-position:-240px -48px;}
|
||||
.icon-align-left{background-position:-264px -48px;}
|
||||
.icon-align-center{background-position:-288px -48px;}
|
||||
.icon-align-right{background-position:-312px -48px;}
|
||||
.icon-align-justify{background-position:-336px -48px;}
|
||||
.icon-list{background-position:-360px -48px;}
|
||||
.icon-indent-left{background-position:-384px -48px;}
|
||||
.icon-indent-right{background-position:-408px -48px;}
|
||||
.icon-facetime-video{background-position:-432px -48px;}
|
||||
.icon-picture{background-position:-456px -48px;}
|
||||
.icon-pencil{background-position:0 -72px;}
|
||||
.icon-map-marker{background-position:-24px -72px;}
|
||||
.icon-adjust{background-position:-48px -72px;}
|
||||
.icon-tint{background-position:-72px -72px;}
|
||||
.icon-edit{background-position:-96px -72px;}
|
||||
.icon-share{background-position:-120px -72px;}
|
||||
.icon-check{background-position:-144px -72px;}
|
||||
.icon-move{background-position:-168px -72px;}
|
||||
.icon-step-backward{background-position:-192px -72px;}
|
||||
.icon-fast-backward{background-position:-216px -72px;}
|
||||
.icon-backward{background-position:-240px -72px;}
|
||||
.icon-play{background-position:-264px -72px;}
|
||||
.icon-pause{background-position:-288px -72px;}
|
||||
.icon-stop{background-position:-312px -72px;}
|
||||
.icon-forward{background-position:-336px -72px;}
|
||||
.icon-fast-forward{background-position:-360px -72px;}
|
||||
.icon-step-forward{background-position:-384px -72px;}
|
||||
.icon-eject{background-position:-408px -72px;}
|
||||
.icon-chevron-left{background-position:-432px -72px;}
|
||||
.icon-chevron-right{background-position:-456px -72px;}
|
||||
.icon-plus-sign{background-position:0 -96px;}
|
||||
.icon-minus-sign{background-position:-24px -96px;}
|
||||
.icon-remove-sign{background-position:-48px -96px;}
|
||||
.icon-ok-sign{background-position:-72px -96px;}
|
||||
.icon-question-sign{background-position:-96px -96px;}
|
||||
.icon-info-sign{background-position:-120px -96px;}
|
||||
.icon-screenshot{background-position:-144px -96px;}
|
||||
.icon-remove-circle{background-position:-168px -96px;}
|
||||
.icon-ok-circle{background-position:-192px -96px;}
|
||||
.icon-ban-circle{background-position:-216px -96px;}
|
||||
.icon-arrow-left{background-position:-240px -96px;}
|
||||
.icon-arrow-right{background-position:-264px -96px;}
|
||||
.icon-arrow-up{background-position:-289px -96px;}
|
||||
.icon-arrow-down{background-position:-312px -96px;}
|
||||
.icon-share-alt{background-position:-336px -96px;}
|
||||
.icon-resize-full{background-position:-360px -96px;}
|
||||
.icon-resize-small{background-position:-384px -96px;}
|
||||
.icon-plus{background-position:-408px -96px;}
|
||||
.icon-minus{background-position:-433px -96px;}
|
||||
.icon-asterisk{background-position:-456px -96px;}
|
||||
.icon-exclamation-sign{background-position:0 -120px;}
|
||||
.icon-gift{background-position:-24px -120px;}
|
||||
.icon-leaf{background-position:-48px -120px;}
|
||||
.icon-fire{background-position:-72px -120px;}
|
||||
.icon-eye-open{background-position:-96px -120px;}
|
||||
.icon-eye-close{background-position:-120px -120px;}
|
||||
.icon-warning-sign{background-position:-144px -120px;}
|
||||
.icon-plane{background-position:-168px -120px;}
|
||||
.icon-calendar{background-position:-192px -120px;}
|
||||
.icon-random{background-position:-216px -120px;}
|
||||
.icon-comment{background-position:-240px -120px;}
|
||||
.icon-magnet{background-position:-264px -120px;}
|
||||
.icon-chevron-up{background-position:-288px -120px;}
|
||||
.icon-chevron-down{background-position:-313px -119px;}
|
||||
.icon-retweet{background-position:-336px -120px;}
|
||||
.icon-shopping-cart{background-position:-360px -120px;}
|
||||
.icon-folder-close{background-position:-384px -120px;}
|
||||
.icon-folder-open{background-position:-408px -120px;}
|
||||
.icon-resize-vertical{background-position:-432px -119px;}
|
||||
.icon-resize-horizontal{background-position:-456px -118px;}
|
||||
.dropdown{position:relative;}
|
||||
.dropdown-toggle{*margin-bottom:-3px;}
|
||||
.dropdown-toggle:active,.open .dropdown-toggle{outline:0;}
|
||||
.caret{display:inline-block;width:0;height:0;text-indent:-99999px;*text-indent:0;vertical-align:top;border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid #000000;opacity:0.3;filter:alpha(opacity=30);content:"\2193";}
|
||||
.dropdown .caret{margin-top:8px;margin-left:2px;}
|
||||
.dropdown:hover .caret,.open.dropdown .caret{opacity:1;filter:alpha(opacity=100);}
|
||||
.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;float:left;display:none;min-width:160px;max-width:220px;_width:160px;padding:4px 0;margin:0;list-style:none;background-color:#ffffff;border-color:#ccc;border-color:rgba(0, 0, 0, 0.2);border-style:solid;border-width:1px;-webkit-border-radius:0 0 5px 5px;-moz-border-radius:0 0 5px 5px;border-radius:0 0 5px 5px;-webkit-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-moz-box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);box-shadow:0 5px 10px rgba(0, 0, 0, 0.2);-webkit-background-clip:padding-box;-moz-background-clip:padding;background-clip:padding-box;*border-right-width:2px;*border-bottom-width:2px;}.dropdown-menu.bottom-up{top:auto;bottom:100%;margin-bottom:2px;}
|
||||
.dropdown-menu .divider{height:1px;margin:5px 1px;overflow:hidden;background-color:#e5e5e5;border-bottom:1px solid #ffffff;*width:100%;*margin:-5px 0 5px;}
|
||||
.dropdown-menu a{display:block;padding:3px 15px;clear:both;font-weight:normal;line-height:18px;color:#555555;white-space:nowrap;}
|
||||
.dropdown-menu li>a:hover,.dropdown-menu .active>a,.dropdown-menu .active>a:hover{color:#ffffff;text-decoration:none;background-color:#0088cc;}
|
||||
.dropdown.open{*z-index:1000;}.dropdown.open .dropdown-toggle{color:#ffffff;background:#ccc;background:rgba(0, 0, 0, 0.3);}
|
||||
.dropdown.open .dropdown-menu{display:block;}
|
||||
.typeahead{margin-top:2px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
|
||||
.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #eee;border:1px solid rgba(0, 0, 0, 0.05);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 1px rgba(0, 0, 0, 0.05);}.well blockquote{border-color:#ddd;border-color:rgba(0, 0, 0, 0.15);}
|
||||
.fade{-webkit-transition:opacity 0.15s linear;-moz-transition:opacity 0.15s linear;-ms-transition:opacity 0.15s linear;-o-transition:opacity 0.15s linear;transition:opacity 0.15s linear;opacity:0;}.fade.in{opacity:1;}
|
||||
.collapse{-webkit-transition:height 0.35s ease;-moz-transition:height 0.35s ease;-ms-transition:height 0.35s ease;-o-transition:height 0.35s ease;transition:height 0.35s ease;position:relative;overflow:hidden;height:0;}.collapse.in{height:auto;}
|
||||
.close{float:right;font-size:20px;font-weight:bold;line-height:18px;color:#000000;text-shadow:0 1px 0 #ffffff;opacity:0.2;filter:alpha(opacity=20);}.close:hover{color:#000000;text-decoration:none;opacity:0.4;filter:alpha(opacity=40);cursor:pointer;}
|
||||
.btn{display:inline-block;padding:4px 10px 4px;font-size:13px;line-height:18px;color:#333333;text-align:center;text-shadow:0 1px 1px rgba(255, 255, 255, 0.75);background-color:#fafafa;background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));background-image:-webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);background-image:-ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:-o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-image:linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);background-repeat:no-repeat;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);border:1px solid #ccc;border-bottom-color:#bbb;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);cursor:pointer;*margin-left:.3em;}.btn:first-child{*margin-left:0;}
|
||||
.btn:hover{color:#333333;text-decoration:none;background-color:#e6e6e6;background-position:0 -15px;-webkit-transition:background-position 0.1s linear;-moz-transition:background-position 0.1s linear;-ms-transition:background-position 0.1s linear;-o-transition:background-position 0.1s linear;transition:background-position 0.1s linear;}
|
||||
.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px;}
|
||||
.btn.active,.btn:active{background-image:none;-webkit-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 2px 4px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);background-color:#e6e6e6;background-color:#d9d9d9 \9;color:rgba(0, 0, 0, 0.5);outline:0;}
|
||||
.btn.disabled,.btn[disabled]{cursor:default;background-image:none;background-color:#e6e6e6;opacity:0.65;filter:alpha(opacity=65);-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;}
|
||||
.btn-large{padding:9px 14px;font-size:15px;line-height:normal;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
|
||||
.btn-large .icon{margin-top:1px;}
|
||||
.btn-small{padding:5px 9px;font-size:11px;line-height:16px;}
|
||||
.btn-small .icon{margin-top:-1px;}
|
||||
.btn-primary,.btn-primary:hover,.btn-warning,.btn-warning:hover,.btn-danger,.btn-danger:hover,.btn-success,.btn-success:hover,.btn-info,.btn-info:hover{text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);color:#ffffff;}
|
||||
.btn-primary.active,.btn-warning.active,.btn-danger.active,.btn-success.active,.btn-info.active{color:rgba(255, 255, 255, 0.75);}
|
||||
.btn-primary{background-color:#006dcc;background-image:-moz-linear-gradient(top, #0088cc, #0044cc);background-image:-ms-linear-gradient(top, #0088cc, #0044cc);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc));background-image:-webkit-linear-gradient(top, #0088cc, #0044cc);background-image:-o-linear-gradient(top, #0088cc, #0044cc);background-image:linear-gradient(top, #0088cc, #0044cc);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#0088cc', endColorstr='#0044cc', GradientType=0);border-color:#0044cc #0044cc #002a80;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-primary:hover,.btn-primary:active,.btn-primary.active,.btn-primary.disabled,.btn-primary[disabled]{background-color:#0044cc;}
|
||||
.btn-primary:active,.btn-primary.active{background-color:#003399 \9;}
|
||||
.btn-warning{background-color:#faa732;background-image:-moz-linear-gradient(top, #fbb450, #f89406);background-image:-ms-linear-gradient(top, #fbb450, #f89406);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#fbb450), to(#f89406));background-image:-webkit-linear-gradient(top, #fbb450, #f89406);background-image:-o-linear-gradient(top, #fbb450, #f89406);background-image:linear-gradient(top, #fbb450, #f89406);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fbb450', endColorstr='#f89406', GradientType=0);border-color:#f89406 #f89406 #ad6704;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-warning:hover,.btn-warning:active,.btn-warning.active,.btn-warning.disabled,.btn-warning[disabled]{background-color:#f89406;}
|
||||
.btn-warning:active,.btn-warning.active{background-color:#c67605 \9;}
|
||||
.btn-danger{background-color:#da4f49;background-image:-moz-linear-gradient(top, #ee5f5b, #bd362f);background-image:-ms-linear-gradient(top, #ee5f5b, #bd362f);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#bd362f));background-image:-webkit-linear-gradient(top, #ee5f5b, #bd362f);background-image:-o-linear-gradient(top, #ee5f5b, #bd362f);background-image:linear-gradient(top, #ee5f5b, #bd362f);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#bd362f', GradientType=0);border-color:#bd362f #bd362f #802420;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-danger:hover,.btn-danger:active,.btn-danger.active,.btn-danger.disabled,.btn-danger[disabled]{background-color:#bd362f;}
|
||||
.btn-danger:active,.btn-danger.active{background-color:#942a25 \9;}
|
||||
.btn-success{background-color:#5bb75b;background-image:-moz-linear-gradient(top, #62c462, #51a351);background-image:-ms-linear-gradient(top, #62c462, #51a351);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#51a351));background-image:-webkit-linear-gradient(top, #62c462, #51a351);background-image:-o-linear-gradient(top, #62c462, #51a351);background-image:linear-gradient(top, #62c462, #51a351);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#51a351', GradientType=0);border-color:#51a351 #51a351 #387038;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-success:hover,.btn-success:active,.btn-success.active,.btn-success.disabled,.btn-success[disabled]{background-color:#51a351;}
|
||||
.btn-success:active,.btn-success.active{background-color:#408140 \9;}
|
||||
.btn-info{background-color:#49afcd;background-image:-moz-linear-gradient(top, #5bc0de, #2f96b4);background-image:-ms-linear-gradient(top, #5bc0de, #2f96b4);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#2f96b4));background-image:-webkit-linear-gradient(top, #5bc0de, #2f96b4);background-image:-o-linear-gradient(top, #5bc0de, #2f96b4);background-image:linear-gradient(top, #5bc0de, #2f96b4);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#2f96b4', GradientType=0);border-color:#2f96b4 #2f96b4 #1f6377;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);}.btn-info:hover,.btn-info:active,.btn-info.active,.btn-info.disabled,.btn-info[disabled]{background-color:#2f96b4;}
|
||||
.btn-info:active,.btn-info.active{background-color:#24748c \9;}
|
||||
button.btn,input[type="submit"].btn{*padding-top:2px;*padding-bottom:2px;}button.btn::-moz-focus-inner,input[type="submit"].btn::-moz-focus-inner{padding:0;border:0;}
|
||||
button.btn.large,input[type="submit"].btn.large{*padding-top:7px;*padding-bottom:7px;}
|
||||
button.btn.small,input[type="submit"].btn.small{*padding-top:3px;*padding-bottom:3px;}
|
||||
.btn-group{position:relative;*zoom:1;*margin-left:.3em;}.btn-group:before,.btn-group:after{display:table;content:"";}
|
||||
.btn-group:after{clear:both;}
|
||||
.btn-group:first-child{*margin-left:0;}
|
||||
.btn-group+.btn-group{margin-left:5px;}
|
||||
.btn-toolbar{margin-top:9px;margin-bottom:9px;}.btn-toolbar .btn-group{display:inline-block;*display:inline;*zoom:1;}
|
||||
.btn-group .btn{position:relative;float:left;margin-left:-1px;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
|
||||
.btn-group .btn:first-child{margin-left:0;-webkit-border-top-left-radius:4px;-moz-border-radius-topleft:4px;border-top-left-radius:4px;-webkit-border-bottom-left-radius:4px;-moz-border-radius-bottomleft:4px;border-bottom-left-radius:4px;}
|
||||
.btn-group .btn:last-child,.btn-group .dropdown-toggle{-webkit-border-top-right-radius:4px;-moz-border-radius-topright:4px;border-top-right-radius:4px;-webkit-border-bottom-right-radius:4px;-moz-border-radius-bottomright:4px;border-bottom-right-radius:4px;}
|
||||
.btn-group .btn.large:first-child{margin-left:0;-webkit-border-top-left-radius:6px;-moz-border-radius-topleft:6px;border-top-left-radius:6px;-webkit-border-bottom-left-radius:6px;-moz-border-radius-bottomleft:6px;border-bottom-left-radius:6px;}
|
||||
.btn-group .btn.large:last-child,.btn-group .large.dropdown-toggle{-webkit-border-top-right-radius:6px;-moz-border-radius-topright:6px;border-top-right-radius:6px;-webkit-border-bottom-right-radius:6px;-moz-border-radius-bottomright:6px;border-bottom-right-radius:6px;}
|
||||
.btn-group .btn:hover,.btn-group .btn:focus,.btn-group .btn:active,.btn-group .btn.active{z-index:2;}
|
||||
.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0;}
|
||||
.btn-group .dropdown-toggle{padding-left:8px;padding-right:8px;-webkit-box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 1px 0 0 rgba(255, 255, 255, 0.125),inset 0 1px 0 rgba(255, 255, 255, 0.2),0 1px 2px rgba(0, 0, 0, 0.05);*padding-top:5px;*padding-bottom:5px;}
|
||||
.btn-group.open{*z-index:1000;}.btn-group.open .dropdown-menu{display:block;margin-top:1px;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
|
||||
.btn-group.open .dropdown-toggle{background-image:none;-webkit-box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:inset 0 1px 6px rgba(0, 0, 0, 0.15),0 1px 2px rgba(0, 0, 0, 0.05);}
|
||||
.btn .caret{margin-top:7px;margin-left:0;}
|
||||
.btn:hover .caret,.open.btn-group .caret{opacity:1;filter:alpha(opacity=100);}
|
||||
.btn-primary .caret,.btn-danger .caret,.btn-info .caret,.btn-success .caret{border-top-color:#ffffff;opacity:0.75;filter:alpha(opacity=75);}
|
||||
.btn-small .caret{margin-top:4px;}
|
||||
.alert{padding:8px 35px 8px 14px;margin-bottom:18px;text-shadow:0 1px 0 rgba(255, 255, 255, 0.5);background-color:#fcf8e3;border:1px solid #fbeed5;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
|
||||
.alert,.alert-heading{color:#c09853;}
|
||||
.alert .close{position:relative;top:-2px;right:-21px;line-height:18px;}
|
||||
.alert-success{background-color:#dff0d8;border-color:#d6e9c6;}
|
||||
.alert-success,.alert-success .alert-heading{color:#468847;}
|
||||
.alert-danger,.alert-error{background-color:#f2dede;border-color:#eed3d7;}
|
||||
.alert-danger,.alert-error,.alert-danger .alert-heading,.alert-error .alert-heading{color:#b94a48;}
|
||||
.alert-info{background-color:#d9edf7;border-color:#bce8f1;}
|
||||
.alert-info,.alert-info .alert-heading{color:#3a87ad;}
|
||||
.alert-block{padding-top:14px;padding-bottom:14px;}
|
||||
.alert-block>p,.alert-block>ul{margin-bottom:0;}
|
||||
.alert-block p+p{margin-top:5px;}
|
||||
.nav{margin-left:0;margin-bottom:18px;list-style:none;}
|
||||
.nav>li>a{display:block;}
|
||||
.nav>li>a:hover{text-decoration:none;background-color:#eeeeee;}
|
||||
.nav-list{padding-left:14px;padding-right:14px;margin-bottom:0;}
|
||||
.nav-list>li>a,.nav-list .nav-header{display:block;padding:3px 15px;margin-left:-15px;margin-right:-15px;text-shadow:0 1px 0 rgba(255, 255, 255, 0.5);}
|
||||
.nav-list .nav-header{font-size:11px;font-weight:bold;line-height:18px;color:#999999;text-transform:uppercase;}
|
||||
.nav-list>li+.nav-header{margin-top:9px;}
|
||||
.nav-list .active>a,.nav-list .active>a:hover{color:#ffffff;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.2);background-color:#0088cc;}
|
||||
.nav-list [class^="icon-"]{margin-right:2px;}
|
||||
.nav-tabs,.nav-pills{*zoom:1;}.nav-tabs:before,.nav-pills:before,.nav-tabs:after,.nav-pills:after{display:table;content:"";}
|
||||
.nav-tabs:after,.nav-pills:after{clear:both;}
|
||||
.nav-tabs>li,.nav-pills>li{float:left;}
|
||||
.nav-tabs>li>a,.nav-pills>li>a{padding-right:12px;padding-left:12px;margin-right:2px;line-height:14px;}
|
||||
.nav-tabs{border-bottom:1px solid #ddd;}
|
||||
.nav-tabs>li{margin-bottom:-1px;}
|
||||
.nav-tabs>li>a{padding-top:9px;padding-bottom:9px;border:1px solid transparent;-webkit-border-radius:4px 4px 0 0;-moz-border-radius:4px 4px 0 0;border-radius:4px 4px 0 0;}.nav-tabs>li>a:hover{border-color:#eeeeee #eeeeee #dddddd;}
|
||||
.nav-tabs>.active>a,.nav-tabs>.active>a:hover{color:#555555;background-color:#ffffff;border:1px solid #ddd;border-bottom-color:transparent;cursor:default;}
|
||||
.nav-pills>li>a{padding-top:8px;padding-bottom:8px;margin-top:2px;margin-bottom:2px;-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;}
|
||||
.nav-pills .active>a,.nav-pills .active>a:hover{color:#ffffff;background-color:#0088cc;}
|
||||
.nav-stacked>li{float:none;}
|
||||
.nav-stacked>li>a{margin-right:0;}
|
||||
.nav-tabs.nav-stacked{border-bottom:0;}
|
||||
.nav-tabs.nav-stacked>li>a{border:1px solid #ddd;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
|
||||
.nav-tabs.nav-stacked>li:first-child>a{-webkit-border-radius:4px 4px 0 0;-moz-border-radius:4px 4px 0 0;border-radius:4px 4px 0 0;}
|
||||
.nav-tabs.nav-stacked>li:last-child>a{-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px;}
|
||||
.nav-tabs.nav-stacked>li>a:hover{border-color:#ddd;z-index:2;}
|
||||
.nav-pills.nav-stacked>li>a{margin-bottom:3px;}
|
||||
.nav-pills.nav-stacked>li:last-child>a{margin-bottom:1px;}
|
||||
.nav-tabs .dropdown-menu,.nav-pills .dropdown-menu{margin-top:1px;border-width:1px;}
|
||||
.nav-pills .dropdown-menu{-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
|
||||
.nav-tabs .dropdown-toggle .caret,.nav-pills .dropdown-toggle .caret{border-top-color:#0088cc;margin-top:6px;}
|
||||
.nav-tabs .dropdown-toggle:hover .caret,.nav-pills .dropdown-toggle:hover .caret{border-top-color:#005580;}
|
||||
.nav-tabs .active .dropdown-toggle .caret,.nav-pills .active .dropdown-toggle .caret{border-top-color:#333333;}
|
||||
.nav>.dropdown.active>a:hover{color:#000000;cursor:pointer;}
|
||||
.nav-tabs .open .dropdown-toggle,.nav-pills .open .dropdown-toggle,.nav>.open.active>a:hover{color:#ffffff;background-color:#999999;border-color:#999999;}
|
||||
.nav .open .caret,.nav .open.active .caret,.nav .open a:hover .caret{border-top-color:#ffffff;opacity:1;filter:alpha(opacity=100);}
|
||||
.tabs-stacked .open>a:hover{border-color:#999999;}
|
||||
.tabbable{*zoom:1;}.tabbable:before,.tabbable:after{display:table;content:"";}
|
||||
.tabbable:after{clear:both;}
|
||||
.tabs-below .nav-tabs,.tabs-right .nav-tabs,.tabs-left .nav-tabs{border-bottom:0;}
|
||||
.tab-content>.tab-pane,.pill-content>.pill-pane{display:none;}
|
||||
.tab-content>.active,.pill-content>.active{display:block;}
|
||||
.tabs-below .nav-tabs{border-top:1px solid #ddd;}
|
||||
.tabs-below .nav-tabs>li{margin-top:-1px;margin-bottom:0;}
|
||||
.tabs-below .nav-tabs>li>a{-webkit-border-radius:0 0 4px 4px;-moz-border-radius:0 0 4px 4px;border-radius:0 0 4px 4px;}.tabs-below .nav-tabs>li>a:hover{border-bottom-color:transparent;border-top-color:#ddd;}
|
||||
.tabs-below .nav-tabs .active>a,.tabs-below .nav-tabs .active>a:hover{border-color:transparent #ddd #ddd #ddd;}
|
||||
.tabs-left .nav-tabs>li,.tabs-right .nav-tabs>li{float:none;}
|
||||
.tabs-left .nav-tabs>li>a,.tabs-right .nav-tabs>li>a{min-width:74px;margin-right:0;margin-bottom:3px;}
|
||||
.tabs-left .nav-tabs{float:left;margin-right:19px;border-right:1px solid #ddd;}
|
||||
.tabs-left .nav-tabs>li>a{margin-right:-1px;-webkit-border-radius:4px 0 0 4px;-moz-border-radius:4px 0 0 4px;border-radius:4px 0 0 4px;}
|
||||
.tabs-left .nav-tabs>li>a:hover{border-color:#eeeeee #dddddd #eeeeee #eeeeee;}
|
||||
.tabs-left .nav-tabs .active>a,.tabs-left .nav-tabs .active>a:hover{border-color:#ddd transparent #ddd #ddd;*border-right-color:#ffffff;}
|
||||
.tabs-right .nav-tabs{float:right;margin-left:19px;border-left:1px solid #ddd;}
|
||||
.tabs-right .nav-tabs>li>a{margin-left:-1px;-webkit-border-radius:0 4px 4px 0;-moz-border-radius:0 4px 4px 0;border-radius:0 4px 4px 0;}
|
||||
.tabs-right .nav-tabs>li>a:hover{border-color:#eeeeee #eeeeee #eeeeee #dddddd;}
|
||||
.tabs-right .nav-tabs .active>a,.tabs-right .nav-tabs .active>a:hover{border-color:#ddd #ddd #ddd transparent;*border-left-color:#ffffff;}
|
||||
.navbar{overflow:visible;margin-bottom:18px;}
|
||||
.navbar-inner{padding-left:20px;padding-right:20px;background-color:#2c2c2c;background-image:-moz-linear-gradient(top, #333333, #222222);background-image:-ms-linear-gradient(top, #333333, #222222);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#333333), to(#222222));background-image:-webkit-linear-gradient(top, #333333, #222222);background-image:-o-linear-gradient(top, #333333, #222222);background-image:linear-gradient(top, #333333, #222222);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);-moz-box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);box-shadow:0 1px 3px rgba(0, 0, 0, 0.25),inset 0 -1px 0 rgba(0, 0, 0, 0.1);}
|
||||
.btn-navbar{display:none;float:right;padding:7px 10px;margin-left:5px;margin-right:5px;background-color:#2c2c2c;background-image:-moz-linear-gradient(top, #333333, #222222);background-image:-ms-linear-gradient(top, #333333, #222222);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#333333), to(#222222));background-image:-webkit-linear-gradient(top, #333333, #222222);background-image:-o-linear-gradient(top, #333333, #222222);background-image:linear-gradient(top, #333333, #222222);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0);border-color:#222222 #222222 #000000;border-color:rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);-webkit-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);-moz-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.1),0 1px 0 rgba(255, 255, 255, 0.075);}.btn-navbar:hover,.btn-navbar:active,.btn-navbar.active,.btn-navbar.disabled,.btn-navbar[disabled]{background-color:#222222;}
|
||||
.btn-navbar:active,.btn-navbar.active{background-color:#080808 \9;}
|
||||
.btn-navbar .icon-bar{display:block;width:18px;height:2px;background-color:#f5f5f5;-webkit-border-radius:1px;-moz-border-radius:1px;border-radius:1px;-webkit-box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);-moz-box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);box-shadow:0 1px 0 rgba(0, 0, 0, 0.25);}
|
||||
.btn-navbar .icon-bar+.icon-bar{margin-top:3px;}
|
||||
.nav-collapse.collapse{height:auto;}
|
||||
.navbar .brand:hover{text-decoration:none;}
|
||||
.navbar .brand{float:left;display:block;padding:8px 20px 12px;margin-left:-20px;font-size:20px;font-weight:200;line-height:1;color:#ffffff;}
|
||||
.navbar .navbar-text{margin-bottom:0;line-height:40px;color:#999999;}.navbar .navbar-text a:hover{color:#ffffff;background-color:transparent;}
|
||||
.navbar .btn,.navbar .btn-group{margin-top:5px;}
|
||||
.navbar .btn-group .btn{margin-top:0;}
|
||||
.navbar-form{margin-bottom:0;*zoom:1;}.navbar-form:before,.navbar-form:after{display:table;content:"";}
|
||||
.navbar-form:after{clear:both;}
|
||||
.navbar-form input,.navbar-form select{display:inline-block;margin-top:5px;margin-bottom:0;}
|
||||
.navbar-form .radio,.navbar-form .checkbox{margin-top:5px;}
|
||||
.navbar-form input[type="image"],.navbar-form input[type="checkbox"],.navbar-form input[type="radio"]{margin-top:3px;}
|
||||
.navbar-search{position:relative;float:left;margin-top:6px;margin-bottom:0;}.navbar-search .search-query{padding:4px 9px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:13px;font-weight:normal;line-height:1;color:#ffffff;color:rgba(255, 255, 255, 0.75);background:#666;background:rgba(255, 255, 255, 0.3);border:1px solid #111;-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1),0 1px 0px rgba(255, 255, 255, 0.15);-webkit-transition:none;-moz-transition:none;-ms-transition:none;-o-transition:none;transition:none;}.navbar-search .search-query :-moz-placeholder{color:#eeeeee;}
|
||||
.navbar-search .search-query::-webkit-input-placeholder{color:#eeeeee;}
|
||||
.navbar-search .search-query:hover{color:#ffffff;background-color:#999999;background-color:rgba(255, 255, 255, 0.5);}
|
||||
.navbar-search .search-query:focus,.navbar-search .search-query.focused{padding:5px 10px;color:#333333;text-shadow:0 1px 0 #ffffff;background-color:#ffffff;border:0;-webkit-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);-moz-box-shadow:0 0 3px rgba(0, 0, 0, 0.15);box-shadow:0 0 3px rgba(0, 0, 0, 0.15);outline:0;}
|
||||
.navbar-fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030;}
|
||||
.navbar-fixed-top .navbar-inner{padding-left:0;padding-right:0;-webkit-border-radius:0;-moz-border-radius:0;border-radius:0;}
|
||||
.navbar .nav{position:relative;left:0;display:block;float:left;margin:0 10px 0 0;}
|
||||
.navbar .nav.pull-right{float:right;}
|
||||
.navbar .nav>li{display:block;float:left;}
|
||||
.navbar .nav>li>a{float:none;padding:10px 10px 11px;line-height:19px;color:#999999;text-decoration:none;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);}
|
||||
.navbar .nav>li>a:hover{background-color:transparent;color:#ffffff;text-decoration:none;}
|
||||
.navbar .nav .active>a,.navbar .nav .active>a:hover{color:#ffffff;text-decoration:none;background-color:#222222;background-color:rgba(0, 0, 0, 0.5);}
|
||||
.navbar .divider-vertical{height:40px;width:1px;margin:0 9px;overflow:hidden;background-color:#222222;border-right:1px solid #333333;}
|
||||
.navbar .nav.pull-right{margin-left:10px;margin-right:0;}
|
||||
.navbar .dropdown-menu{margin-top:1px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}.navbar .dropdown-menu:before{content:'';display:inline-block;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid #ccc;border-bottom-color:rgba(0, 0, 0, 0.2);position:absolute;top:-7px;left:9px;}
|
||||
.navbar .dropdown-menu:after{content:'';display:inline-block;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #ffffff;position:absolute;top:-6px;left:10px;}
|
||||
.navbar .nav .dropdown-toggle .caret,.navbar .nav .open.dropdown .caret{border-top-color:#ffffff;}
|
||||
.navbar .nav .active .caret{opacity:1;filter:alpha(opacity=100);}
|
||||
.navbar .nav .open>.dropdown-toggle,.navbar .nav .active>.dropdown-toggle,.navbar .nav .open.active>.dropdown-toggle{background-color:transparent;}
|
||||
.navbar .nav .active>.dropdown-toggle:hover{color:#ffffff;}
|
||||
.navbar .nav.pull-right .dropdown-menu{left:auto;right:0;}.navbar .nav.pull-right .dropdown-menu:before{left:auto;right:12px;}
|
||||
.navbar .nav.pull-right .dropdown-menu:after{left:auto;right:13px;}
|
||||
.breadcrumb{padding:7px 14px;margin:0 0 18px;background-color:#fbfbfb;background-image:-moz-linear-gradient(top, #ffffff, #f5f5f5);background-image:-ms-linear-gradient(top, #ffffff, #f5f5f5);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#f5f5f5));background-image:-webkit-linear-gradient(top, #ffffff, #f5f5f5);background-image:-o-linear-gradient(top, #ffffff, #f5f5f5);background-image:linear-gradient(top, #ffffff, #f5f5f5);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#f5f5f5', GradientType=0);border:1px solid #ddd;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;}.breadcrumb li{display:inline;text-shadow:0 1px 0 #ffffff;}
|
||||
.breadcrumb .divider{padding:0 5px;color:#999999;}
|
||||
.breadcrumb .active a{color:#333333;}
|
||||
.pagination{height:36px;margin:18px 0;}
|
||||
.pagination ul{display:inline-block;*display:inline;*zoom:1;margin-left:0;margin-bottom:0;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;-webkit-box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);-moz-box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);box-shadow:0 1px 2px rgba(0, 0, 0, 0.05);}
|
||||
.pagination li{display:inline;}
|
||||
.pagination a{float:left;padding:0 14px;line-height:34px;text-decoration:none;border:1px solid #ddd;border-left-width:0;}
|
||||
.pagination a:hover,.pagination .active a{background-color:#f5f5f5;}
|
||||
.pagination .active a{color:#999999;cursor:default;}
|
||||
.pagination .disabled a,.pagination .disabled a:hover{color:#999999;background-color:transparent;cursor:default;}
|
||||
.pagination li:first-child a{border-left-width:1px;-webkit-border-radius:3px 0 0 3px;-moz-border-radius:3px 0 0 3px;border-radius:3px 0 0 3px;}
|
||||
.pagination li:last-child a{-webkit-border-radius:0 3px 3px 0;-moz-border-radius:0 3px 3px 0;border-radius:0 3px 3px 0;}
|
||||
.pagination-centered{text-align:center;}
|
||||
.pagination-right{text-align:right;}
|
||||
.pager{margin-left:0;margin-bottom:18px;list-style:none;text-align:center;*zoom:1;}.pager:before,.pager:after{display:table;content:"";}
|
||||
.pager:after{clear:both;}
|
||||
.pager li{display:inline;}
|
||||
.pager a{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;-webkit-border-radius:15px;-moz-border-radius:15px;border-radius:15px;}
|
||||
.pager a:hover{text-decoration:none;background-color:#f5f5f5;}
|
||||
.pager .next a{float:right;}
|
||||
.pager .previous a{float:left;}
|
||||
.modal-open .dropdown-menu{z-index:2050;}
|
||||
.modal-open .dropdown.open{*z-index:2050;}
|
||||
.modal-open .popover{z-index:2060;}
|
||||
.modal-open .tooltip{z-index:2070;}
|
||||
.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000000;}.modal-backdrop.fade{opacity:0;}
|
||||
.modal-backdrop,.modal-backdrop.fade.in{opacity:0.8;filter:alpha(opacity=80);}
|
||||
.modal{position:fixed;top:50%;left:50%;z-index:1050;max-height:500px;overflow:auto;width:560px;margin:-250px 0 0 -280px;background-color:#ffffff;border:1px solid #999;border:1px solid rgba(0, 0, 0, 0.3);*border:1px solid #999;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.modal.fade{-webkit-transition:opacity .3s linear, top .3s ease-out;-moz-transition:opacity .3s linear, top .3s ease-out;-ms-transition:opacity .3s linear, top .3s ease-out;-o-transition:opacity .3s linear, top .3s ease-out;transition:opacity .3s linear, top .3s ease-out;top:-25%;}
|
||||
.modal.fade.in{top:50%;}
|
||||
.modal-header{padding:9px 15px;border-bottom:1px solid #eee;}.modal-header .close{margin-top:2px;}
|
||||
.modal-body{padding:15px;}
|
||||
.modal-footer{padding:14px 15px 15px;margin-bottom:0;background-color:#f5f5f5;border-top:1px solid #ddd;-webkit-border-radius:0 0 6px 6px;-moz-border-radius:0 0 6px 6px;border-radius:0 0 6px 6px;-webkit-box-shadow:inset 0 1px 0 #ffffff;-moz-box-shadow:inset 0 1px 0 #ffffff;box-shadow:inset 0 1px 0 #ffffff;*zoom:1;}.modal-footer:before,.modal-footer:after{display:table;content:"";}
|
||||
.modal-footer:after{clear:both;}
|
||||
.modal-footer .btn{float:right;margin-left:5px;margin-bottom:0;}
|
||||
.tooltip{position:absolute;z-index:1020;display:block;visibility:visible;padding:5px;font-size:11px;opacity:0;filter:alpha(opacity=0);}.tooltip.in{opacity:0.8;filter:alpha(opacity=80);}
|
||||
.tooltip.top{margin-top:-2px;}
|
||||
.tooltip.right{margin-left:2px;}
|
||||
.tooltip.bottom{margin-top:2px;}
|
||||
.tooltip.left{margin-left:-2px;}
|
||||
.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #000000;}
|
||||
.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000000;}
|
||||
.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:5px solid #000000;}
|
||||
.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-right:5px solid #000000;}
|
||||
.tooltip-inner{max-width:200px;padding:3px 8px;color:#ffffff;text-align:center;text-decoration:none;background-color:#000000;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
|
||||
.tooltip-arrow{position:absolute;width:0;height:0;}
|
||||
.popover{position:absolute;top:0;left:0;z-index:1010;display:none;padding:5px;}.popover.top{margin-top:-5px;}
|
||||
.popover.right{margin-left:5px;}
|
||||
.popover.bottom{margin-top:5px;}
|
||||
.popover.left{margin-left:-5px;}
|
||||
.popover.top .arrow{bottom:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid #000000;}
|
||||
.popover.right .arrow{top:50%;left:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-right:5px solid #000000;}
|
||||
.popover.bottom .arrow{top:0;left:50%;margin-left:-5px;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:5px solid #000000;}
|
||||
.popover.left .arrow{top:50%;right:0;margin-top:-5px;border-top:5px solid transparent;border-bottom:5px solid transparent;border-left:5px solid #000000;}
|
||||
.popover .arrow{position:absolute;width:0;height:0;}
|
||||
.popover-inner{padding:3px;width:280px;overflow:hidden;background:#000000;background:rgba(0, 0, 0, 0.8);-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;-webkit-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);-moz-box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);box-shadow:0 3px 7px rgba(0, 0, 0, 0.3);}
|
||||
.popover-title{padding:9px 15px;line-height:1;background-color:#f5f5f5;border-bottom:1px solid #eee;-webkit-border-radius:3px 3px 0 0;-moz-border-radius:3px 3px 0 0;border-radius:3px 3px 0 0;}
|
||||
.popover-content{padding:14px;background-color:#ffffff;-webkit-border-radius:0 0 3px 3px;-moz-border-radius:0 0 3px 3px;border-radius:0 0 3px 3px;-webkit-background-clip:padding-box;-moz-background-clip:padding-box;background-clip:padding-box;}.popover-content p,.popover-content ul,.popover-content ol{margin-bottom:0;}
|
||||
.thumbnails{margin-left:-20px;list-style:none;*zoom:1;}.thumbnails:before,.thumbnails:after{display:table;content:"";}
|
||||
.thumbnails:after{clear:both;}
|
||||
.thumbnails>li{float:left;margin:0 0 18px 20px;}
|
||||
.thumbnail{display:block;padding:4px;line-height:1;border:1px solid #ddd;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);-moz-box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);box-shadow:0 1px 1px rgba(0, 0, 0, 0.075);}
|
||||
a.thumbnail:hover{border-color:#0088cc;-webkit-box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);-moz-box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);box-shadow:0 1px 4px rgba(0, 105, 214, 0.25);}
|
||||
.thumbnail>img{display:block;max-width:100%;margin-left:auto;margin-right:auto;}
|
||||
.thumbnail .caption{padding:9px;}
|
||||
.label{padding:1px 3px 2px;font-size:9.75px;font-weight:bold;color:#ffffff;text-transform:uppercase;background-color:#999999;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}
|
||||
.label-important{background-color:#b94a48;}
|
||||
.label-warning{background-color:#f89406;}
|
||||
.label-success{background-color:#468847;}
|
||||
.label-info{background-color:#3a87ad;}
|
||||
@-webkit-keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}@-moz-keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}@keyframes progress-bar-stripes{from{background-position:0 0;} to{background-position:40px 0;}}.progress{overflow:hidden;height:18px;margin-bottom:18px;background-color:#f7f7f7;background-image:-moz-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-ms-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#f5f5f5), to(#f9f9f9));background-image:-webkit-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:-o-linear-gradient(top, #f5f5f5, #f9f9f9);background-image:linear-gradient(top, #f5f5f5, #f9f9f9);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#f5f5f5', endColorstr='#f9f9f9', GradientType=0);-webkit-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);-moz-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.1);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
|
||||
.progress .bar{width:0%;height:18px;color:#ffffff;font-size:12px;text-align:center;text-shadow:0 -1px 0 rgba(0, 0, 0, 0.25);background-color:#0e90d2;background-image:-moz-linear-gradient(top, #149bdf, #0480be);background-image:-ms-linear-gradient(top, #149bdf, #0480be);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#149bdf), to(#0480be));background-image:-webkit-linear-gradient(top, #149bdf, #0480be);background-image:-o-linear-gradient(top, #149bdf, #0480be);background-image:linear-gradient(top, #149bdf, #0480be);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#149bdf', endColorstr='#0480be', GradientType=0);-webkit-box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);-moz-box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);box-shadow:inset 0 -1px 0 rgba(0, 0, 0, 0.15);-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-transition:width 0.6s ease;-moz-transition:width 0.6s ease;-ms-transition:width 0.6s ease;-o-transition:width 0.6s ease;transition:width 0.6s ease;}
|
||||
.progress-striped .bar{background-color:#62c462;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);-webkit-background-size:40px 40px;-moz-background-size:40px 40px;-o-background-size:40px 40px;background-size:40px 40px;}
|
||||
.progress.active .bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-moz-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite;}
|
||||
.progress-danger .bar{background-color:#dd514c;background-image:-moz-linear-gradient(top, #ee5f5b, #c43c35);background-image:-ms-linear-gradient(top, #ee5f5b, #c43c35);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#c43c35));background-image:-webkit-linear-gradient(top, #ee5f5b, #c43c35);background-image:-o-linear-gradient(top, #ee5f5b, #c43c35);background-image:linear-gradient(top, #ee5f5b, #c43c35);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#c43c35', GradientType=0);}
|
||||
.progress-danger.progress-striped .bar{background-color:#ee5f5b;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
|
||||
.progress-success .bar{background-color:#5eb95e;background-image:-moz-linear-gradient(top, #62c462, #57a957);background-image:-ms-linear-gradient(top, #62c462, #57a957);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#57a957));background-image:-webkit-linear-gradient(top, #62c462, #57a957);background-image:-o-linear-gradient(top, #62c462, #57a957);background-image:linear-gradient(top, #62c462, #57a957);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#62c462', endColorstr='#57a957', GradientType=0);}
|
||||
.progress-success.progress-striped .bar{background-color:#62c462;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
|
||||
.progress-info .bar{background-color:#4bb1cf;background-image:-moz-linear-gradient(top, #5bc0de, #339bb9);background-image:-ms-linear-gradient(top, #5bc0de, #339bb9);background-image:-webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#339bb9));background-image:-webkit-linear-gradient(top, #5bc0de, #339bb9);background-image:-o-linear-gradient(top, #5bc0de, #339bb9);background-image:linear-gradient(top, #5bc0de, #339bb9);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#5bc0de', endColorstr='#339bb9', GradientType=0);}
|
||||
.progress-info.progress-striped .bar{background-color:#5bc0de;background-image:-webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);}
|
||||
.accordion{margin-bottom:18px;}
|
||||
.accordion-group{margin-bottom:2px;border:1px solid #e5e5e5;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;}
|
||||
.accordion-heading{border-bottom:0;}
|
||||
.accordion-heading .accordion-toggle{display:block;padding:8px 15px;}
|
||||
.accordion-inner{padding:9px 15px;border-top:1px solid #e5e5e5;}
|
||||
.carousel{position:relative;margin-bottom:18px;line-height:1;}
|
||||
.carousel-inner{overflow:hidden;width:100%;position:relative;}
|
||||
.carousel .item{display:none;position:relative;-webkit-transition:0.6s ease-in-out left;-moz-transition:0.6s ease-in-out left;-ms-transition:0.6s ease-in-out left;-o-transition:0.6s ease-in-out left;transition:0.6s ease-in-out left;}
|
||||
.carousel .item>img{display:block;line-height:1;}
|
||||
.carousel .active,.carousel .next,.carousel .prev{display:block;}
|
||||
.carousel .active{left:0;}
|
||||
.carousel .next,.carousel .prev{position:absolute;top:0;width:100%;}
|
||||
.carousel .next{left:100%;}
|
||||
.carousel .prev{left:-100%;}
|
||||
.carousel .next.left,.carousel .prev.right{left:0;}
|
||||
.carousel .active.left{left:-100%;}
|
||||
.carousel .active.right{left:100%;}
|
||||
.carousel-control{position:absolute;top:40%;left:15px;width:40px;height:40px;margin-top:-20px;font-size:60px;font-weight:100;line-height:30px;color:#ffffff;text-align:center;background:#222222;border:3px solid #ffffff;-webkit-border-radius:23px;-moz-border-radius:23px;border-radius:23px;opacity:0.5;filter:alpha(opacity=50);}.carousel-control.right{left:auto;right:15px;}
|
||||
.carousel-control:hover{color:#ffffff;text-decoration:none;opacity:0.9;filter:alpha(opacity=90);}
|
||||
.carousel-caption{position:absolute;left:0;right:0;bottom:0;padding:10px 15px 5px;background:#333333;background:rgba(0, 0, 0, 0.75);}
|
||||
.carousel-caption h4,.carousel-caption p{color:#ffffff;}
|
||||
.hero-unit{padding:60px;margin-bottom:30px;background-color:#f5f5f5;-webkit-border-radius:6px;-moz-border-radius:6px;border-radius:6px;}.hero-unit h1{margin-bottom:0;font-size:60px;line-height:1;letter-spacing:-1px;}
|
||||
.hero-unit p{font-size:18px;font-weight:200;line-height:27px;}
|
||||
.pull-right{float:right;}
|
||||
.pull-left{float:left;}
|
||||
.hide{display:none;}
|
||||
.show{display:block;}
|
||||
.invisible{visibility:hidden;}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
Vendored
-1722
File diff suppressed because it is too large.
Load diff
Vendored
-1
@@ -1 +0,0 @@
|
||||
!function(a){a(function(){"use strict",a.support.transition=function(){var b=document.body||document.documentElement,c=b.style,d=c.transition!==undefined||c.WebkitTransition!==undefined||c.MozTransition!==undefined||c.MsTransition!==undefined||c.OTransition!==undefined;return d&&{end:function(){var b="TransitionEnd";return a.browser.webkit?b="webkitTransitionEnd":a.browser.mozilla?b="transitionend":a.browser.opera&&(b="oTransitionEnd"),b}()}}()})}(window.jQuery),!function(a){"use strict";var b='[data-dismiss="alert"]',c=function(c){a(c).on("click",b,this.close)};c.prototype={constructor:c,close:function(b){function f(){e.remove(),e.trigger("closed")}var c=a(this),d=c.attr("data-target"),e;d||(d=c.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),e=a(d),e.trigger("close"),b&&b.preventDefault(),e.length||(e=c.hasClass("alert")?c:c.parent()),e.removeClass("in"),a.support.transition&&e.hasClass("fade")?e.on(a.support.transition.end,f):f()}},a.fn.alert=function(b){return this.each(function(){var d=a(this),e=d.data("alert");e||d.data("alert",e=new c(this)),typeof b=="string"&&e[b].call(d)})},a.fn.alert.Constructor=c,a(function(){a("body").on("click.alert.data-api",b,c.prototype.close)})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.button.defaults,c)};b.prototype={constructor:b,setState:function(a){var b="disabled",c=this.$element,d=c.data(),e=c.is("input")?"val":"html";a+="Text",d.resetText||c.data("resetText",c[e]()),c[e](d[a]||this.options[a]),setTimeout(function(){a=="loadingText"?c.addClass(b).attr(b,b):c.removeClass(b).removeAttr(b)},0)},toggle:function(){var a=this.$element.parent('[data-toggle="buttons-radio"]');a&&a.find(".active").removeClass("active"),this.$element.toggleClass("active")}},a.fn.button=function(c){return this.each(function(){var d=a(this),e=d.data("button"),f=typeof c=="object"&&c;e||d.data("button",e=new b(this,f)),c=="toggle"?e.toggle():c&&e.setState(c)})},a.fn.button.defaults={loadingText:"loading..."},a.fn.button.Constructor=b,a(function(){a("body").on("click.button.data-api","[data-toggle^=button]",function(b){a(b.target).button("toggle")})})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.carousel.defaults,c),this.options.slide&&this.slide(this.options.slide)};b.prototype={cycle:function(){return this.interval=setInterval(a.proxy(this.next,this),this.options.interval),this},to:function(b){var c=this.$element.find(".active"),d=c.parent().children(),e=d.index(c),f=this;if(b>d.length-1||b<0)return;return this.sliding?this.$element.one("slid",function(){f.to(b)}):e==b?this.pause().cycle():this.slide(b>e?"next":"prev",a(d[b]))},pause:function(){return clearInterval(this.interval),this},next:function(){if(this.sliding)return;return this.slide("next")},prev:function(){if(this.sliding)return;return this.slide("prev")},slide:function(b,c){var d=this.$element.find(".active"),e=c||d[b](),f=this.interval,g=b=="next"?"left":"right",h=b=="next"?"first":"last",i=this;return this.sliding=!0,f&&this.pause(),e=e.length?e:this.$element.find(".item")[h](),!a.support.transition&&this.$element.hasClass("slide")?(this.$element.trigger("slide"),d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger("slid")):(e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),this.$element.trigger("slide"),this.$element.one(a.support.transition.end,function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger("slid")},0)})),f&&this.cycle(),this}},a.fn.carousel=function(c){return this.each(function(){var d=a(this),e=d.data("carousel"),f=typeof c=="object"&&c;e||d.data("carousel",e=new b(this,f)),typeof c=="number"?e.to(c):typeof c=="string"||(c=f.slide)?e[c]():e.cycle()})},a.fn.carousel.defaults={interval:5e3},a.fn.carousel.Constructor=b,a(function(){a("body").on("click.carousel.data-api","[data-slide]",function(b){var c=a(this),d,e=a(c.attr("data-target")||(d=c.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,"")),f=!e.data("modal")&&a.extend({},e.data(),c.data());e.carousel(f),b.preventDefault()})})}(window.jQuery),!function(a){"use strict";var b=function(b,c){this.$element=a(b),this.options=a.extend({},a.fn.collapse.defaults,c),this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};b.prototype={constructor:b,dimension:function(){var a=this.$element.hasClass("width");return a?"width":"height"},show:function(){var b=this.dimension(),c=a.camelCase(["scroll",b].join("-")),d=this.$parent&&this.$parent.find(".in"),e;d&&d.length&&(e=d.data("collapse"),d.collapse("hide"),e||d.data("collapse",null)),this.$element[b](0),this.transition("addClass","show","shown"),this.$element[b](this.$element[0][c])},hide:function(){var a=this.dimension();this.reset(this.$element[a]()),this.transition("removeClass","hide","hidden"),this.$element[a](0)},reset:functLine truncated
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 923 B |
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
/*! jQuery v1.7.1 jquery.com | jquery.org/license */
|
||||
(function(a,b){function cy(a){return f.isWindow(a)?a:a.nodeType===9?a.defaultView||a.parentWindow:!1}function cv(a){if(!ck[a]){var b=c.body,d=f("<"+a+">").appendTo(b),e=d.css("display");d.remove();if(e==="none"||e===""){cl||(cl=c.createElement("iframe"),cl.frameBorder=cl.width=cl.height=0),b.appendChild(cl);if(!cm||!cl.createElement)cm=(cl.contentWindow||cl.contentDocument).document,cm.write((c.compatMode==="CSS1Compat"?"<!doctype html>":"")+"<html><body>"),cm.close();d=cm.createElement(a),cm.body.appendChild(d),e=f.css(d,"display"),b.removeChild(cl)}ck[a]=e}return ck[a]}function cu(a,b){var c={};f.each(cq.concat.apply([],cq.slice(0,b)),function(){c[this]=a});return c}function ct(){cr=b}function cs(){setTimeout(ct,0);return cr=f.now()}function cj(){try{return new a.ActiveXObject("Microsoft.XMLHTTP")}catch(b){}}function ci(){try{return new a.XMLHttpRequest}catch(b){}}function cc(a,c){a.dataFilter&&(c=a.dataFilter(c,a.dataType));var d=a.dataTypes,e={},g,h,i=d.length,j,k=d[0],l,m,n,o,p;for(g=1;g<i;g++){if(g===1)for(h in a.converters)typeof h=="string"&&(e[h.toLowerCase()]=a.converters[h]);l=k,k=d[g];if(k==="*")k=l;else if(l!=="*"&&l!==k){m=l+" "+k,n=e[m]||e["* "+k];if(!n){p=b;for(o in e){j=o.split(" ");if(j[0]===l||j[0]==="*"){p=e[j[1]+" "+k];if(p){o=e[o],o===!0?n=p:p===!0&&(n=o);break}}}}!n&&!p&&f.error("No conversion from "+m.replace(" "," to ")),n!==!0&&(c=n?n(c):p(o(c)))}}return c}function cb(a,c,d){var e=a.contents,f=a.dataTypes,g=a.responseFields,h,i,j,k;for(i in g)i in d&&(c[g[i]]=d[i]);while(f[0]==="*")f.shift(),h===b&&(h=a.mimeType||c.getResponseHeader("content-type"));if(h)for(i in e)if(e[i]&&e[i].test(h)){f.unshift(i);break}if(f[0]in d)j=f[0];else{for(i in d){if(!f[0]||a.converters[i+" "+f[0]]){j=i;break}k||(k=i)}j=j||k}if(j){j!==f[0]&&f.unshift(j);return d[j]}}function ca(a,b,c,d){if(f.isArray(b))f.each(b,function(b,e){c||bE.test(a)?d(a,e):ca(a+"["+(typeof e=="object"||f.isArray(e)?b:"")+"]",e,c,d)});else if(!c&&b!=null&&typeof b=="object")for(var e in b)ca(a+"["+e+"]",b[e],c,d);else d(a,b)}function b_(a,c){var d,e,g=f.ajaxSettings.flatOptions||{};for(d in c)c[d]!==b&&((g[d]?a:e||(e={}))[d]=c[d]);e&&f.extend(!0,a,e)}function b$(a,c,d,e,f,g){f=f||c.dataTypes[0],g=g||{},g[f]=!0;var h=a[f],i=0,j=h?h.length:0,k=a===bT,l;for(;i<j&&(k||!l);i++)l=h[i](c,d,e),typeof l=="string"&&(!k||g[l]?l=b:(c.dataTypes.unshift(l),l=b$(a,c,d,e,l,g)));(k||!l)&&!g["*"]&&(l=b$(a,c,d,e,"*",g));return l}function bZ(a){return function(b,c){typeof b!="string"&&(c=b,b="*");if(f.isFunction(c)){var d=b.toLowerCase().split(bP),e=0,g=d.length,h,i,j;for(;e<g;e++)h=d[e],j=/^\+/.test(h),j&&(h=h.substr(1)||"*"),i=a[h]=a[h]||[],i[j?"unshift":"push"](c)}}}function bC(a,b,c){var d=b==="width"?a.offsetWidth:a.offsetHeight,e=b==="width"?bx:by,g=0,h=e.length;if(d>0){if(c!=="border")for(;g<h;g++)c||(d-=parseFloat(f.css(a,"padding"+e[g]))||0),c==="margin"?d+=parseFloat(f.css(a,c+e[g]))||0:d-=parseFloat(f.css(a,"border"+e[g]+"Width"))||0;return d+"px"}d=bz(a,b,b);if(d<0||d==null)d=a.style[b]||0;d=parseFloat(d)||0;if(c)for(;g<h;g++)d+=parseFloat(f.css(a,"padding"+e[g]))||0,c!=="padding"&&(d+=parseFloat(f.css(a,"border"+e[g]+"Width"))||0),c==="margin"&&(d+=parseFloat(f.css(a,c+e[g]))||0);return d+"px"}function bp(a,b){b.src?f.ajax({url:b.src,async:!1,dataType:"script"}):f.globalEval((b.text||b.textContent||b.innerHTML||"").replace(bf,"/*$0*/")),b.parentNode&&b.parentNode.removeChild(b)}function bo(a){var b=c.createElement("div");bh.appendChild(b),b.innerHTML=a.outerHTML;return b.firstChild}function bn(a){var b=(a.nodeName||"").toLowerCase();b==="input"?bm(a):b!=="script"&&typeof a.getElementsByTagName!="undefined"&&f.grep(a.getElementsByTagName("input"),bm)}function bm(a){if(a.type==="checkbox"||a.type==="radio")a.defaultChecked=a.checked}function bl(a){return typeof a.getElementsByTagName!="undefined"?a.getElementsByTagName("*"):typeof a.querySelectorAll!="undefined"?a.querySelectorAll("*"):[]}function bk(a,b){var c;if(b.nodeType===1){b.clearAttributes&&b.clearAttributes(),b.mergeAttributes&&b.mergeAttributes(a),c=b.nodeName.toLowerCase();if(c==="object")b.outerHTML=a.outerHTML;else if(c!=="input"||a.type!=="checkbox"&&a.type!=="radio"){if(c==="option")b.selected=a.defaultSelected;else if(c==="input"||c==="textarea")b.defaultValue=a.defaultValue}else a.checked&&(b.defaultChecked=b.checked=a.checked),b.value!==a.value&&(b.value=a.value);b.removeAttribute(f.expando)}}function bj(a,b){if(b.nodeType===1&&!!f.hasData(a)){var c,d,e,g=f._data(a),h=f._data(b,g),i=g.events;if(i){delete h.handle,h.events={};for(c in i)for(d=0,e=i[c].length;d<e;d++)f.event.add(b,c+(i[c][d].namespace?".":"")+i[c][d].namespace,i[c][d],i[c][d].data)}h.data&&(h.data=f.extend({},h.data))}}function bi(a,b){return f.nodeName(a,"table")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function U(a){var b=V.split("|"),c=a.createDocumentFragment();if(c.createElement)while(b.length)c.createElement(b.pop());return c}function T(a,b,c){b=b||0;iLine truncated
|
||||
f.event={add:function(a,c,d,e,g){var h,i,j,k,l,m,n,o,p,q,r,s;if(!(a.nodeType===3||a.nodeType===8||!c||!d||!(h=f._data(a)))){d.handler&&(p=d,d=p.handler),d.guid||(d.guid=f.guid++),j=h.events,j||(h.events=j={}),i=h.handle,i||(h.handle=i=function(a){return typeof f!="undefined"&&(!a||f.event.triggered!==a.type)?f.event.dispatch.apply(i.elem,arguments):b},i.elem=a),c=f.trim(I(c)).split(" ");for(k=0;k<c.length;k++){l=A.exec(c[k])||[],m=l[1],n=(l[2]||"").split(".").sort(),s=f.event.special[m]||{},m=(g?s.delegateType:s.bindType)||m,s=f.event.special[m]||{},o=f.extend({type:m,origType:l[1],data:e,handler:d,guid:d.guid,selector:g,quick:G(g),namespace:n.join(".")},p),r=j[m];if(!r){r=j[m]=[],r.delegateCount=0;if(!s.setup||s.setup.call(a,e,n,i)===!1)a.addEventListener?a.addEventListener(m,i,!1):a.attachEvent&&a.attachEvent("on"+m,i)}s.add&&(s.add.call(a,o),o.handler.guid||(o.handler.guid=d.guid)),g?r.splice(r.delegateCount++,0,o):r.push(o),f.event.global[m]=!0}a=null}},global:{},remove:function(a,b,c,d,e){var g=f.hasData(a)&&f._data(a),h,i,j,k,l,m,n,o,p,q,r,s;if(!!g&&!!(o=g.events)){b=f.trim(I(b||"")).split(" ");for(h=0;h<b.length;h++){i=A.exec(b[h])||[],j=k=i[1],l=i[2];if(!j){for(j in o)f.event.remove(a,j+b[h],c,d,!0);continue}p=f.event.special[j]||{},j=(d?p.delegateType:p.bindType)||j,r=o[j]||[],m=r.length,l=l?new RegExp("(^|\\.)"+l.split(".").sort().join("\\.(?:.*\\.)?")+"(\\.|$)"):null;for(n=0;n<r.length;n++)s=r[n],(e||k===s.origType)&&(!c||c.guid===s.guid)&&(!l||l.test(s.namespace))&&(!d||d===s.selector||d==="**"&&s.selector)&&(r.splice(n--,1),s.selector&&r.delegateCount--,p.remove&&p.remove.call(a,s));r.length===0&&m!==r.length&&((!p.teardown||p.teardown.call(a,l)===!1)&&f.removeEvent(a,j,g.handle),delete o[j])}f.isEmptyObject(o)&&(q=g.handle,q&&(q.elem=null),f.removeData(a,["events","handle"],!0))}},customEvent:{getData:!0,setData:!0,changeData:!0},trigger:function(c,d,e,g){if(!e||e.nodeType!==3&&e.nodeType!==8){var h=c.type||c,i=[],j,k,l,m,n,o,p,q,r,s;if(E.test(h+f.event.triggered))return;h.indexOf("!")>=0&&(h=h.slice(0,-1),k=!0),h.indexOf(".")>=0&&(i=h.split("."),h=i.shift(),i.sort());if((!e||f.event.customEvent[h])&&!f.event.global[h])return;c=typeof c=="object"?c[f.expando]?c:new f.Event(h,c):new f.Event(h),c.type=h,c.isTrigger=!0,c.exclusive=k,c.namespace=i.join("."),c.namespace_re=c.namespace?new RegExp("(^|\\.)"+i.join("\\.(?:.*\\.)?")+"(\\.|$)"):null,o=h.indexOf(":")<0?"on"+h:"";if(!e){j=f.cache;for(l in j)j[l].events&&j[l].events[h]&&f.event.trigger(c,d,j[l].handle.elem,!0);return}c.result=b,c.target||(c.target=e),d=d!=null?f.makeArray(d):[],d.unshift(c),p=f.event.special[h]||{};if(p.trigger&&p.trigger.apply(e,d)===!1)return;r=[[e,p.bindType||h]];if(!g&&!p.noBubble&&!f.isWindow(e)){s=p.delegateType||h,m=E.test(s+h)?e:e.parentNode,n=null;for(;m;m=m.parentNode)r.push([m,s]),n=m;n&&n===e.ownerDocument&&r.push([n.defaultView||n.parentWindow||a,s])}for(l=0;l<r.length&&!c.isPropagationStopped();l++)m=r[l][0],c.type=r[l][1],q=(f._data(m,"events")||{})[c.type]&&f._data(m,"handle"),q&&q.apply(m,d),q=o&&m[o],q&&f.acceptData(m)&&q.apply(m,d)===!1&&c.preventDefault();c.type=h,!g&&!c.isDefaultPrevented()&&(!p._default||p._default.apply(e.ownerDocument,d)===!1)&&(h!=="click"||!f.nodeName(e,"a"))&&f.acceptData(e)&&o&&e[h]&&(h!=="focus"&&h!=="blur"||c.target.offsetWidth!==0)&&!f.isWindow(e)&&(n=e[o],n&&(e[o]=null),f.event.triggered=h,e[h](),f.event.triggered=b,n&&(e[o]=n));return c.result}},dispatch:function(c){c=f.event.fix(c||a.event);var d=(f._data(this,"events")||{})[c.type]||[],e=d.delegateCount,g=[].slice.call(arguments,0),h=!c.exclusive&&!c.namespace,i=[],j,k,l,m,n,o,p,q,r,s,t;g[0]=c,c.delegateTarget=this;if(e&&!c.target.disabled&&(!c.button||c.type!=="click")){m=f(this),m.context=this.ownerDocument||this;for(l=c.target;l!=this;l=l.parentNode||this){o={},q=[],m[0]=l;for(j=0;j<e;j++)r=d[j],s=r.selector,o[s]===b&&(o[s]=r.quick?H(l,r.quick):m.is(s)),o[s]&&q.push(r);q.length&&i.push({elem:l,matches:q})}}d.length>e&&i.push({elem:this,matches:d.slice(e)});for(j=0;j<i.length&&!c.isPropagationStopped();j++){p=i[j],c.currentTarget=p.elem;for(k=0;k<p.matches.length&&!c.isImmediatePropagationStopped();k++){r=p.matches[k];if(h||!c.namespace&&!r.namespace||c.namespace_re&&c.namespace_re.test(r.namespace))c.data=r.data,c.handleObj=r,n=((f.event.special[r.origType]||{}).handle||r.handler).apply(p.elem,g),n!==b&&(c.result=n,n===!1&&(c.preventDefault(),c.stopPropagation()))}}return c.result},props:"attrChange attrName relatedNode srcElement altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(a,b){a.which==null&&(a.which=b.charCode!=null?b.charCode:b.keyCode);return a}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(a,d){var e,f,g,h=d.button,i=d.fromElement;a.pageX==null&&d.cLine truncated
|
||||
{for(var a=0,b;(b=this[a])!=null;a++){b.nodeType===1&&f.cleanData(b.getElementsByTagName("*"));while(b.firstChild)b.removeChild(b.firstChild)}return this},clone:function(a,b){a=a==null?!1:a,b=b==null?a:b;return this.map(function(){return f.clone(this,a,b)})},html:function(a){if(a===b)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(W,""):null;if(typeof a=="string"&&!ba.test(a)&&(f.support.leadingWhitespace||!X.test(a))&&!bg[(Z.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Y,"<$1></$2>");try{for(var c=0,d=this.length;c<d;c++)this[c].nodeType===1&&(f.cleanData(this[c].getElementsByTagName("*")),this[c].innerHTML=a)}catch(e){this.empty().append(a)}}else f.isFunction(a)?this.each(function(b){var c=f(this);c.html(a.call(this,b,c.html()))}):this.empty().append(a);return this},replaceWith:function(a){if(this[0]&&this[0].parentNode){if(f.isFunction(a))return this.each(function(b){var c=f(this),d=c.html();c.replaceWith(a.call(this,b,d))});typeof a!="string"&&(a=f(a).detach());return this.each(function(){var b=this.nextSibling,c=this.parentNode;f(this).remove(),b?f(b).before(a):f(c).append(a)})}return this.length?this.pushStack(f(f.isFunction(a)?a():a),"replaceWith",a):this},detach:function(a){return this.remove(a,!0)},domManip:function(a,c,d){var e,g,h,i,j=a[0],k=[];if(!f.support.checkClone&&arguments.length===3&&typeof j=="string"&&bd.test(j))return this.each(function(){f(this).domManip(a,c,d,!0)});if(f.isFunction(j))return this.each(function(e){var g=f(this);a[0]=j.call(this,e,c?g.html():b),g.domManip(a,c,d)});if(this[0]){i=j&&j.parentNode,f.support.parentNode&&i&&i.nodeType===11&&i.childNodes.length===this.length?e={fragment:i}:e=f.buildFragment(a,this,k),h=e.fragment,h.childNodes.length===1?g=h=h.firstChild:g=h.firstChild;if(g){c=c&&f.nodeName(g,"tr");for(var l=0,m=this.length,n=m-1;l<m;l++)d.call(c?bi(this[l],g):this[l],e.cacheable||m>1&&l<n?f.clone(h,!0,!0):h)}k.length&&f.each(k,bp)}return this}}),f.buildFragment=function(a,b,d){var e,g,h,i,j=a[0];b&&b[0]&&(i=b[0].ownerDocument||b[0]),i.createDocumentFragment||(i=c),a.length===1&&typeof j=="string"&&j.length<512&&i===c&&j.charAt(0)==="<"&&!bb.test(j)&&(f.support.checkClone||!bd.test(j))&&(f.support.html5Clone||!bc.test(j))&&(g=!0,h=f.fragments[j],h&&h!==1&&(e=h)),e||(e=i.createDocumentFragment(),f.clean(a,i,e,d)),g&&(f.fragments[j]=h?e:1);return{fragment:e,cacheable:g}},f.fragments={},f.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){f.fn[a]=function(c){var d=[],e=f(c),g=this.length===1&&this[0].parentNode;if(g&&g.nodeType===11&&g.childNodes.length===1&&e.length===1){e[b](this[0]);return this}for(var h=0,i=e.length;h<i;h++){var j=(h>0?this.clone(!0):this).get();f(e[h])[b](j),d=d.concat(j)}return this.pushStack(d,a,e.selector)}}),f.extend({clone:function(a,b,c){var d,e,g,h=f.support.html5Clone||!bc.test("<"+a.nodeName)?a.cloneNode(!0):bo(a);if((!f.support.noCloneEvent||!f.support.noCloneChecked)&&(a.nodeType===1||a.nodeType===11)&&!f.isXMLDoc(a)){bk(a,h),d=bl(a),e=bl(h);for(g=0;d[g];++g)e[g]&&bk(d[g],e[g])}if(b){bj(a,h);if(c){d=bl(a),e=bl(h);for(g=0;d[g];++g)bj(d[g],e[g])}}d=e=null;return h},clean:function(a,b,d,e){var g;b=b||c,typeof b.createElement=="undefined"&&(b=b.ownerDocument||b[0]&&b[0].ownerDocument||c);var h=[],i;for(var j=0,k;(k=a[j])!=null;j++){typeof k=="number"&&(k+="");if(!k)continue;if(typeof k=="string")if(!_.test(k))k=b.createTextNode(k);else{k=k.replace(Y,"<$1></$2>");var l=(Z.exec(k)||["",""])[1].toLowerCase(),m=bg[l]||bg._default,n=m[0],o=b.createElement("div");b===c?bh.appendChild(o):U(b).appendChild(o),o.innerHTML=m[1]+k+m[2];while(n--)o=o.lastChild;if(!f.support.tbody){var p=$.test(k),q=l==="table"&&!p?o.firstChild&&o.firstChild.childNodes:m[1]==="<table>"&&!p?o.childNodes:[];for(i=q.length-1;i>=0;--i)f.nodeName(q[i],"tbody")&&!q[i].childNodes.length&&q[i].parentNode.removeChild(q[i])}!f.support.leadingWhitespace&&X.test(k)&&o.insertBefore(b.createTextNode(X.exec(k)[0]),o.firstChild),k=o.childNodes}var r;if(!f.support.appendChecked)if(k[0]&&typeof (r=k.length)=="number")for(i=0;i<r;i++)bn(k[i]);else bn(k);k.nodeType?h.push(k):h=f.merge(h,k)}if(d){g=function(a){return!a.type||be.test(a.type)};for(j=0;h[j];j++)if(e&&f.nodeName(h[j],"script")&&(!h[j].type||h[j].type.toLowerCase()==="text/javascript"))e.push(h[j].parentNode?h[j].parentNode.removeChild(h[j]):h[j]);else{if(h[j].nodeType===1){var s=f.grep(h[j].getElementsByTagName("script"),g);h.splice.apply(h,[j+1,0].concat(s))}d.appendChild(h[j])}}return h},cleanData:function(a){var b,c,d=f.cache,e=f.event.special,g=f.support.deleteExpando;for(var h=0,i;(i=a[h])!=null;h++){if(i.nodeName&&f.noData[i.nodeName.toLowerCase()])continue;c=i[f.expando];if(c){b=d[c];if(b&&b.events){for(var j in b.events)e[j]?f.event.remove(i,j):f.removeEvent(i,j,b.handle);b.handle&&(b.handle.elem=null)}g?delete i[f.expando]:i.removeAttribute&&i.removeAttribute(f.expando),delete d[c]}}}});var bq=/alpha\([^)]*\)/i,br=/opacitLine truncated
|
||||
Whitespace-only changes.
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from mdb.models import (
|
||||
Domain,
|
||||
DomainAAAARecord,
|
||||
DomainARecord,
|
||||
DomainCnameRecord,
|
||||
DomainTxtRecord,
|
||||
MailExchange,
|
||||
Nameserver,
|
||||
)
|
||||
from mdb.services.zone_sync import ZoneSyncService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def domain(db):
|
||||
call_command("loaddata", "test_data", verbosity=0)
|
||||
return Domain.objects.get(domain_name="edb.technology")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path):
|
||||
svc = ZoneSyncService(debug=True)
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_domain(db, tmp_path):
|
||||
domain = Domain.objects.create(
|
||||
domain_name="example.org",
|
||||
domain_soa="ns1.example.org",
|
||||
domain_admin="admin@example.org",
|
||||
domain_ipaddr="1.2.3.4",
|
||||
domain_ip6addr="2001:db8::1",
|
||||
domain_filename=str(tmp_path / "db.example.org"),
|
||||
domain_ttl=3600,
|
||||
domain_serial=2024010101,
|
||||
)
|
||||
ns1 = Nameserver.objects.create(hostname="ns1.example.org")
|
||||
ns2 = Nameserver.objects.create(hostname="ns2.example.org")
|
||||
domain.domain_nameservers.set([ns1, ns2])
|
||||
mx = MailExchange.objects.create(priority=10, hostname="mail.example.org")
|
||||
domain.domain_mailexchanges.set([mx])
|
||||
DomainARecord.objects.create(domain=domain, name="ns1", target="1.2.3.4")
|
||||
DomainARecord.objects.create(domain=domain, name="ns2", target="5.6.7.8")
|
||||
DomainARecord.objects.create(domain=domain, name="mail", target="9.10.11.12")
|
||||
DomainARecord.objects.create(domain=domain, name="www", target="1.2.3.4")
|
||||
DomainAAAARecord.objects.create(domain=domain, name="www", target="2001:db8::1")
|
||||
DomainCnameRecord.objects.create(
|
||||
domain=domain, name="ftp", target="www.example.org."
|
||||
)
|
||||
DomainTxtRecord.objects.create(domain=domain, name="@", target='"v=spf1 mx ~all"')
|
||||
return domain
|
||||
@@ -0,0 +1,103 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from mdb.models import Host
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_fixtures(db):
|
||||
call_command("loaddata", "test_data", verbosity=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_with_token(db_with_fixtures):
|
||||
user = User.objects.create(username="admin")
|
||||
token = Token.objects.create(user=user)
|
||||
return user, token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(db_with_fixtures):
|
||||
return Host.objects.first()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_validate_puppet_host_by_secret(api_client, user_with_token, host):
|
||||
_, token = user_with_token
|
||||
data = {
|
||||
"pxe_key": host.pxe_key,
|
||||
"certname": f"{host.hostname}.{host.interface_set.first().domain.domain_name}",
|
||||
}
|
||||
api_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
|
||||
res = api_client.post(reverse("api-validate-host-secret"), data, format="json")
|
||||
assert res.status_code == 200, res.content
|
||||
assert not Host.objects.get(pk=host.pk).pxe_installable
|
||||
|
||||
|
||||
def post_validate(api_client, token, certname, pxe_key):
|
||||
api_client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
|
||||
return api_client.post(
|
||||
reverse("api-validate-host-secret"),
|
||||
{"certname": certname, "pxe_key": pxe_key},
|
||||
format="json",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("change", "certname", "error"),
|
||||
[
|
||||
({}, "nosuchhost.edb.technology", "not found"),
|
||||
({}, "tut.nosuchdomain.example", "not found"),
|
||||
({"pxe_installable": False}, "tut.edb.technology", "not installable"),
|
||||
({"pxe_key": ""}, "tut.edb.technology", "has no pxe_key"),
|
||||
],
|
||||
)
|
||||
def test_validate_rejects(api_client, user_with_token, host, change, certname, error):
|
||||
_, token = user_with_token
|
||||
Host.objects.filter(pk=host.pk).update(**change)
|
||||
|
||||
res = post_validate(api_client, token, certname, host.pxe_key)
|
||||
|
||||
assert res.status_code == 400
|
||||
assert error in str(res.content)
|
||||
|
||||
|
||||
def test_validate_rejects_wrong_key(api_client, user_with_token, host):
|
||||
_, token = user_with_token
|
||||
res = post_validate(api_client, token, "tut.edb.technology", "wrong")
|
||||
assert res.status_code == 400
|
||||
assert "does not match" in str(res.content)
|
||||
# A wrong guess must not use up the installation.
|
||||
host.refresh_from_db()
|
||||
assert host.pxe_installable
|
||||
assert host.pxe_key
|
||||
|
||||
|
||||
def test_validate_key_works_only_once(api_client, user_with_token, host):
|
||||
_, token = user_with_token
|
||||
key = host.pxe_key
|
||||
assert (
|
||||
post_validate(api_client, token, "tut.edb.technology", key).status_code == 200
|
||||
)
|
||||
assert (
|
||||
post_validate(api_client, token, "tut.edb.technology", key).status_code == 400
|
||||
)
|
||||
|
||||
|
||||
def test_validate_requires_a_token(api_client, host):
|
||||
res = api_client.post(
|
||||
reverse("api-validate-host-secret"),
|
||||
{"certname": "tut.edb.technology", "pxe_key": host.pxe_key},
|
||||
format="json",
|
||||
)
|
||||
assert res.status_code == 401
|
||||
assert Host.objects.get(pk=host.pk).pxe_installable
|
||||
@@ -0,0 +1,121 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mdb.services.dhcp_sync import DhcpSyncService
|
||||
|
||||
|
||||
def make_config(serial=2, active_serial=1):
|
||||
config = MagicMock()
|
||||
config.serial = serial
|
||||
config.active_serial = active_serial
|
||||
config.dhcpd_configuration.return_value = (
|
||||
"# dhcpd config\nsubnet 10.0.0.0 netmask 255.255.255.0 {}\n"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
return DhcpSyncService()
|
||||
|
||||
|
||||
# --- needs_update ---
|
||||
|
||||
|
||||
def test_needs_update_true_when_serials_differ(service):
|
||||
config = make_config(serial=2, active_serial=1)
|
||||
assert service.needs_update(config) is True
|
||||
|
||||
|
||||
def test_needs_update_false_when_serials_match(service):
|
||||
config = make_config(serial=1, active_serial=1)
|
||||
assert service.needs_update(config) is False
|
||||
|
||||
|
||||
def test_needs_update_true_when_force_even_if_serials_match():
|
||||
svc = DhcpSyncService(force=True)
|
||||
config = make_config(serial=1, active_serial=1)
|
||||
assert svc.needs_update(config) is True
|
||||
|
||||
|
||||
# --- calculate_diff ---
|
||||
|
||||
|
||||
def test_calculate_diff_identical_returns_empty(service):
|
||||
config = make_config()
|
||||
assert service.calculate_diff(config, "line\n", "line\n") == ""
|
||||
|
||||
|
||||
def test_calculate_diff_format(service):
|
||||
config = make_config(serial=2, active_serial=1)
|
||||
diff = service.calculate_diff(config, "new line\n", "old line\n")
|
||||
assert "*** 1" in diff
|
||||
assert "--- 2" in diff
|
||||
assert "! old line" in diff
|
||||
assert "! new line" in diff
|
||||
|
||||
|
||||
# --- write_config ---
|
||||
|
||||
|
||||
def test_write_config_creates_file_with_config_contents(service, tmp_path):
|
||||
config = make_config()
|
||||
output_path = str(tmp_path / "dhcpd.conf")
|
||||
|
||||
service.write_config(config, output_path)
|
||||
|
||||
with open(output_path) as f:
|
||||
assert f.read() == config.dhcpd_configuration.return_value
|
||||
|
||||
|
||||
def test_write_config_updates_active_serial(service, tmp_path):
|
||||
config = make_config(serial=5, active_serial=3)
|
||||
output_path = str(tmp_path / "dhcpd.conf")
|
||||
|
||||
service.write_config(config, output_path)
|
||||
|
||||
assert config.active_serial == 5
|
||||
config.save.assert_called_once()
|
||||
|
||||
|
||||
def test_write_config_returns_old_serial(service, tmp_path):
|
||||
config = make_config(serial=2, active_serial=1)
|
||||
output_path = str(tmp_path / "dhcpd.conf")
|
||||
|
||||
old_serial, diff = service.write_config(config, output_path)
|
||||
|
||||
assert old_serial == 1
|
||||
|
||||
|
||||
def test_write_config_diff_reflects_changes(service, tmp_path):
|
||||
output_path = str(tmp_path / "dhcpd.conf")
|
||||
with open(output_path, "w") as f:
|
||||
f.write("old content\n")
|
||||
|
||||
config = make_config(serial=2, active_serial=1)
|
||||
config.dhcpd_configuration.return_value = "new content\n"
|
||||
|
||||
old_serial, diff = service.write_config(config, output_path)
|
||||
|
||||
assert "old content" in diff
|
||||
assert "new content" in diff
|
||||
|
||||
|
||||
# --- restart_service ---
|
||||
|
||||
|
||||
def test_restart_service_returns_true_on_success(service):
|
||||
with patch("mdb.services.dhcp_sync.getstatusoutput", return_value=(0, "")):
|
||||
success, output = service.restart_service()
|
||||
assert success is True
|
||||
assert output == ""
|
||||
|
||||
|
||||
def test_restart_service_returns_false_on_failure(service):
|
||||
with patch(
|
||||
"mdb.services.dhcp_sync.getstatusoutput", return_value=(1, "Job failed")
|
||||
):
|
||||
success, output = service.restart_service()
|
||||
assert success is False
|
||||
assert output == "Job failed"
|
||||
@@ -0,0 +1,135 @@
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mdb.management.commands.dhcp_sync import Command as CommandClass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmd():
|
||||
c = CommandClass()
|
||||
c.stdout = StringIO()
|
||||
c.stderr = StringIO()
|
||||
return c
|
||||
|
||||
|
||||
def make_service(serial=2, active_serial=1, restart_success=True, restart_output=""):
|
||||
svc = MagicMock()
|
||||
svc.force = False
|
||||
svc.restart_command = "/usr/sbin/service isc-dhcp-server restart"
|
||||
config = MagicMock()
|
||||
config.serial = serial
|
||||
config.active_serial = active_serial
|
||||
config.dhcpd_configuration.return_value = "# dhcpd config\n"
|
||||
svc.get_config.return_value = config
|
||||
svc.needs_update.return_value = serial != active_serial
|
||||
svc.write_config.return_value = (active_serial, "- old\n+ new")
|
||||
svc.restart_service.return_value = (restart_success, restart_output)
|
||||
return svc, config
|
||||
|
||||
|
||||
# --- handle: nothing to do ---
|
||||
|
||||
|
||||
def test_handle_prints_nothing_to_do_when_no_update_needed(cmd):
|
||||
svc, config = make_service(serial=1, active_serial=1)
|
||||
svc.needs_update.return_value = False
|
||||
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
|
||||
cmd.handle(output=None, force=False, restart=False)
|
||||
assert "Nothing to do" in cmd.stdout.getvalue()
|
||||
|
||||
|
||||
# --- handle: stdout mode ---
|
||||
|
||||
|
||||
def test_handle_prints_config_to_stdout_when_no_output_path(cmd):
|
||||
svc, config = make_service()
|
||||
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
|
||||
cmd.handle(output=None, force=False, restart=False)
|
||||
assert "# dhcpd config" in cmd.stdout.getvalue()
|
||||
assert "Not writing to file" in cmd.stdout.getvalue()
|
||||
|
||||
|
||||
def test_handle_does_not_write_file_when_no_output_path(cmd):
|
||||
svc, config = make_service()
|
||||
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
|
||||
cmd.handle(output=None, force=False, restart=False)
|
||||
svc.write_config.assert_not_called()
|
||||
|
||||
|
||||
# --- handle: file mode ---
|
||||
|
||||
|
||||
def test_handle_calls_write_config_when_output_given(cmd, tmp_path):
|
||||
svc, config = make_service()
|
||||
output_path = str(tmp_path / "dhcpd.conf")
|
||||
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
|
||||
cmd.handle(output=output_path, force=False, restart=False)
|
||||
svc.write_config.assert_called_once_with(config, output_path)
|
||||
|
||||
|
||||
def test_handle_does_not_restart_when_restart_false(cmd, tmp_path):
|
||||
svc, config = make_service()
|
||||
output_path = str(tmp_path / "dhcpd.conf")
|
||||
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
|
||||
with patch.object(cmd, "restart_and_notify") as mock_restart:
|
||||
cmd.handle(output=output_path, force=False, restart=False)
|
||||
mock_restart.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_calls_restart_and_notify_when_restart_true(cmd, tmp_path):
|
||||
svc, config = make_service()
|
||||
output_path = str(tmp_path / "dhcpd.conf")
|
||||
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
|
||||
with patch.object(cmd, "restart_and_notify") as mock_restart:
|
||||
cmd.handle(output=output_path, force=False, restart=True)
|
||||
mock_restart.assert_called_once()
|
||||
|
||||
|
||||
# --- restart_and_notify ---
|
||||
|
||||
|
||||
def test_restart_and_notify_success_writes_ok(cmd):
|
||||
svc, config = make_service(restart_success=True)
|
||||
with patch("mdb.management.commands.dhcp_sync.discord"):
|
||||
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
|
||||
assert "ok" in cmd.stdout.getvalue()
|
||||
|
||||
|
||||
def test_restart_and_notify_success_calls_discord(cmd):
|
||||
svc, config = make_service(restart_success=True)
|
||||
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
|
||||
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
|
||||
mock_discord.notify.assert_called_once()
|
||||
assert "✅" in mock_discord.notify.call_args.kwargs["subject"]
|
||||
|
||||
|
||||
def test_restart_and_notify_success_passes_diff_as_preformatted(cmd):
|
||||
svc, config = make_service(restart_success=True)
|
||||
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
|
||||
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
|
||||
assert mock_discord.notify.call_args.kwargs["preformatted"] == "- old\n+ new"
|
||||
|
||||
|
||||
def test_restart_and_notify_failure_writes_fail_and_exits(cmd):
|
||||
svc, config = make_service(restart_success=False, restart_output="Job failed")
|
||||
with patch("mdb.management.commands.dhcp_sync.discord"):
|
||||
with patch("getpass.getuser", return_value="testuser"):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cmd.restart_and_notify(svc, config, old_serial=1, diff="")
|
||||
assert exc.value.code == 1
|
||||
assert "fail" in cmd.stdout.getvalue()
|
||||
|
||||
|
||||
def test_restart_and_notify_failure_discord_includes_command_and_output(cmd):
|
||||
svc, config = make_service(restart_success=False, restart_output="Job failed")
|
||||
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
|
||||
with patch("getpass.getuser", return_value="testuser"):
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.restart_and_notify(svc, config, old_serial=1, diff="")
|
||||
subject = mock_discord.notify.call_args.kwargs["subject"]
|
||||
message = mock_discord.notify.call_args.kwargs["message"]
|
||||
assert "⚠️" in subject
|
||||
assert "isc-dhcp-server" in message
|
||||
assert "Job failed" in message
|
||||
@@ -0,0 +1,82 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mdb.services import discord
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def webhook_url(settings):
|
||||
settings.DISCORD_NOTIFICATION_WEBHOOK = "https://discord.example/webhook"
|
||||
|
||||
|
||||
def test_notify_does_nothing_without_webhook(settings):
|
||||
settings.DISCORD_NOTIFICATION_WEBHOOK = None
|
||||
with patch("mdb.services.discord._send_json") as mock:
|
||||
discord.notify("Subject", "message")
|
||||
mock.assert_not_called()
|
||||
|
||||
|
||||
def test_notify_sends_json_for_short_content():
|
||||
with patch("mdb.services.discord._send_json") as mock:
|
||||
discord.notify("Subject", "message")
|
||||
mock.assert_called_once()
|
||||
_, content = mock.call_args.args
|
||||
assert "**Subject**" in content
|
||||
assert "message" in content
|
||||
|
||||
|
||||
def test_notify_includes_preformatted_as_diff_code_block():
|
||||
with patch("mdb.services.discord._send_json") as mock:
|
||||
discord.notify("Subject", "message", preformatted="- old\n+ new")
|
||||
_, content = mock.call_args.args
|
||||
assert "```diff\n- old\n+ new\n```" in content
|
||||
|
||||
|
||||
def test_notify_sends_file_when_content_exceeds_limit():
|
||||
long_diff = "x" * discord.MAX_CONTENT_LENGTH
|
||||
with patch("mdb.services.discord._send_as_file") as mock:
|
||||
discord.notify("Subject", "message", preformatted=long_diff)
|
||||
mock.assert_called_once()
|
||||
|
||||
|
||||
def test_notify_sends_json_when_content_fits():
|
||||
with patch("mdb.services.discord._send_json") as mock:
|
||||
discord.notify("Subject", "short message")
|
||||
mock.assert_called_once()
|
||||
|
||||
|
||||
def test_send_json_posts_with_mdb_username():
|
||||
mock_resp = MagicMock()
|
||||
with patch("requests.post", return_value=mock_resp) as mock_post:
|
||||
discord._send_json("https://discord.example/webhook", "hello")
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["username"] == "MDB"
|
||||
assert payload["content"] == "hello"
|
||||
mock_resp.raise_for_status.assert_called_once()
|
||||
|
||||
|
||||
def test_send_as_file_posts_multipart_with_attachment():
|
||||
mock_resp = MagicMock()
|
||||
with patch("requests.post", return_value=mock_resp) as mock_post:
|
||||
discord._send_as_file(
|
||||
"https://discord.example/webhook", "Subject", "full content"
|
||||
)
|
||||
assert mock_post.call_args.kwargs.get("files") or "files" in str(
|
||||
mock_post.call_args
|
||||
)
|
||||
mock_resp.raise_for_status.assert_called_once()
|
||||
|
||||
|
||||
def test_send_as_file_content_mentions_attachment():
|
||||
import json
|
||||
|
||||
mock_resp = MagicMock()
|
||||
with patch("requests.post", return_value=mock_resp) as mock_post:
|
||||
discord._send_as_file(
|
||||
"https://discord.example/webhook", "MySubject", "full content"
|
||||
)
|
||||
data = mock_post.call_args.kwargs["data"]
|
||||
payload = json.loads(data["payload_json"])
|
||||
assert "MySubject" in payload["content"]
|
||||
assert "attachment" in payload["content"]
|
||||
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from mdb.forms import InterfaceForm
|
||||
from mdb.models import Host, Interface, Ip4Address
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures(db):
|
||||
call_command("loaddata", "test_data", verbosity=0)
|
||||
|
||||
|
||||
def form(instance=None, **overrides):
|
||||
data = {
|
||||
"name": "eth1",
|
||||
"macaddr": "00:11:22:33:44:55",
|
||||
"host": Host.objects.get().pk,
|
||||
"domain": 1,
|
||||
"ip4address": Ip4Address.objects.get(address="10.0.3.10").pk,
|
||||
"pxe_filename": "",
|
||||
} | overrides
|
||||
return InterfaceForm(data=data, instance=instance)
|
||||
|
||||
|
||||
def test_valid(fixtures):
|
||||
f = form()
|
||||
assert f.is_valid(), f.errors
|
||||
|
||||
|
||||
def test_invalid_mac_is_a_form_error(fixtures):
|
||||
# Used to raise KeyError, a 500 in the admin.
|
||||
f = form(macaddr="not a mac")
|
||||
assert not f.is_valid()
|
||||
assert "macaddr" in f.errors
|
||||
|
||||
|
||||
def test_interface_without_ip4address(fixtures):
|
||||
f = form(ip4address="")
|
||||
assert f.is_valid(), f.errors
|
||||
|
||||
|
||||
@pytest.mark.parametrize("macaddr", ["f0:de:f1:58:be:1a", "F0:DE:F1:58:BE:1A"])
|
||||
def test_mac_already_in_subnet(fixtures, macaddr):
|
||||
f = form(macaddr=macaddr)
|
||||
assert not f.is_valid()
|
||||
assert f.non_field_errors() == ["Macaddr already in this subnet"]
|
||||
|
||||
|
||||
def test_mac_in_use_by_interface_without_ip4address(fixtures):
|
||||
# Used to raise AttributeError on the other interface's missing address.
|
||||
Interface.objects.update(ip4address=None)
|
||||
f = form(macaddr="f0:de:f1:58:be:1a")
|
||||
assert f.is_valid(), f.errors
|
||||
|
||||
|
||||
def test_editing_interface_does_not_clash_with_itself(fixtures):
|
||||
interface = Interface.objects.get()
|
||||
f = form(
|
||||
instance=interface,
|
||||
name="eth0",
|
||||
macaddr=interface.macaddr,
|
||||
ip4address=interface.ip4address.pk,
|
||||
)
|
||||
assert f.is_valid(), f.errors
|
||||
@@ -0,0 +1,142 @@
|
||||
"""The dhcpd.conf and reverse zones generated from the test_data fixture."""
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mdb.models import DhcpConfig, Interface, Ip4Subnet, Ip6Subnet
|
||||
from mdb.services.zone_sync import ZoneSyncService
|
||||
|
||||
EXPECTED_DHCPD_CONF = """\
|
||||
# Autogenerated configuration 2024-01-01 00:00:00
|
||||
default-lease-time 600;
|
||||
max-lease-time 7200;
|
||||
log-facility local7;
|
||||
ddns-update-style none;
|
||||
|
||||
# clients
|
||||
subnet 10.0.3.0 netmask 255.255.255.0 {
|
||||
\toption domain-name "edb.technology";
|
||||
\toption domain-name-servers 158.36.190.194, 158.36.190.199, 8.8.8.8;
|
||||
\toption routers 158.36.186.1;
|
||||
\tdeny unknown-clients;
|
||||
\trange 10.0.3.2 10.0.3.254;
|
||||
}
|
||||
|
||||
host tut {
|
||||
\thardware ethernet f0:de:f1:58:be:1a;
|
||||
\tfixed-address tut.edb.technology;
|
||||
\tif substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:00007" \
|
||||
or substring(option vendor-class-identifier, 0, 20) = "PXEClient:Arch:00009" {
|
||||
\t\tfilename "grubx64.efi";
|
||||
\t} else {
|
||||
\t\tfilename "pxelinux.0";
|
||||
\t}
|
||||
}
|
||||
"""
|
||||
|
||||
SOA_AND_NS = """\
|
||||
$TTL 3600
|
||||
@ IN SOA sega.neuf.no. sysadmin.neuf.no. (
|
||||
\t{serial}\t; serial
|
||||
\t28800\t; refresh
|
||||
\t7200\t; retry
|
||||
\t604800\t; expire
|
||||
\t86400 )\t; minimum ttl
|
||||
;
|
||||
;
|
||||
@\tIN\tNS\tsega.neuf.no.
|
||||
@\tIN\tNS\tatari.neuf.no.
|
||||
;
|
||||
"""
|
||||
|
||||
EXPECTED_IP4_REVERSE = (
|
||||
SOA_AND_NS.format(serial=2024010101)
|
||||
+ "2 \tIN\tPTR\ttut.edb.technology.\n"
|
||||
)
|
||||
|
||||
EXPECTED_IP6_REVERSE = (
|
||||
SOA_AND_NS.format(serial=1)
|
||||
+ "2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0\tPTR\ttut.edb.technology.\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures(db):
|
||||
# Loading the interface bumps the subnet's serial to the frozen day's first.
|
||||
with freeze_time("2024-01-01 00:00:00"):
|
||||
call_command("loaddata", "test_data", verbosity=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(tmp_path):
|
||||
svc = ZoneSyncService()
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
return svc
|
||||
|
||||
|
||||
def body(zone_file):
|
||||
"""A zone file without its header comments, which name the file and the time."""
|
||||
return zone_file[zone_file.index("$TTL") :]
|
||||
|
||||
|
||||
@freeze_time("2024-01-01 00:00:00")
|
||||
def test_dhcpd_configuration(fixtures):
|
||||
assert DhcpConfig.objects.get().dhcpd_configuration() == EXPECTED_DHCPD_CONF
|
||||
|
||||
|
||||
@freeze_time("2024-01-01 00:00:00")
|
||||
def test_dhcpd_configuration_authoritative(fixtures):
|
||||
DhcpConfig.objects.update(authoritative=True)
|
||||
conf = DhcpConfig.objects.get().dhcpd_configuration()
|
||||
assert conf.splitlines()[1] == "authoritative;"
|
||||
|
||||
|
||||
def test_dhcpd_configuration_leaves_out_non_dhcp_hosts(fixtures):
|
||||
Interface.objects.update(dhcp_client=False)
|
||||
assert "host tut" not in DhcpConfig.objects.get().dhcpd_configuration()
|
||||
|
||||
|
||||
def test_dhcpd_configuration_without_pxe_filename(fixtures):
|
||||
Interface.objects.update(pxe_filename="")
|
||||
conf = DhcpConfig.objects.get().dhcpd_configuration()
|
||||
assert "host tut {" in conf
|
||||
assert "filename" not in conf
|
||||
|
||||
|
||||
def test_dhcpd_configuration_with_custom_pxe_filename(fixtures):
|
||||
Interface.objects.update(pxe_filename="custom.efi")
|
||||
conf = DhcpConfig.objects.get().dhcpd_configuration()
|
||||
assert '\tfilename "custom.efi";\n' in conf
|
||||
assert "vendor-class-identifier" not in conf
|
||||
|
||||
|
||||
def test_dhcpd_configuration_without_dynamic_range(fixtures):
|
||||
Ip4Subnet.objects.update(dhcp_dynamic=False)
|
||||
assert "range" not in DhcpConfig.objects.get().dhcpd_configuration()
|
||||
|
||||
|
||||
def test_ip4_reverse_zone(fixtures, service):
|
||||
zone = Ip4Subnet.objects.get()
|
||||
contents = zone.zone_file_contents()
|
||||
assert body(contents) == EXPECTED_IP4_REVERSE
|
||||
status, output = service.check_zone(zone, contents)
|
||||
assert status == 0, output
|
||||
|
||||
|
||||
def test_ip4_reverse_zone_leaves_out_unassigned_addresses(fixtures):
|
||||
Interface.objects.update(ip4address=None)
|
||||
assert "PTR" not in Ip4Subnet.objects.get().zone_file_contents()
|
||||
|
||||
|
||||
def test_ip6_reverse_zone(fixtures, service):
|
||||
zone = Ip6Subnet.objects.get()
|
||||
contents = zone.zone_file_contents()
|
||||
assert body(contents) == EXPECTED_IP6_REVERSE
|
||||
status, output = service.check_zone(zone, contents)
|
||||
assert status == 0, output
|
||||
|
||||
|
||||
def test_ip6_reverse_zone_name():
|
||||
zone = Ip6Subnet(network="2001:16d8:eeb6:3", netmask=64)
|
||||
assert zone.reverse_zone_name == "3.0.0.0.6.b.e.e.8.d.6.1.1.0.0.2.ip6.arpa"
|
||||
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
|
||||
from mdb.models import Ip4Subnet, Ip6Subnet
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"network,netmask,expected",
|
||||
[
|
||||
# /24 and shorter -> standard octet-boundary name
|
||||
("203.0.113.0", "255.255.255.0", "113.0.203.in-addr.arpa"),
|
||||
("10.0.3.0", "255.255.255.0", "3.0.10.in-addr.arpa"),
|
||||
("10.0.0.0", "255.255.0.0", "0.0.10.in-addr.arpa"),
|
||||
# longer than /24 -> RFC 2317 classless name
|
||||
("192.0.2.192", "255.255.255.192", "192-26.2.0.192.in-addr.arpa"),
|
||||
("10.0.0.0", "255.255.255.252", "0-30.0.0.10.in-addr.arpa"),
|
||||
("10.1.2.0", "255.255.255.128", "0-25.2.1.10.in-addr.arpa"),
|
||||
# host bits set -> normalized down to the network address
|
||||
("192.0.2.200", "255.255.255.192", "192-26.2.0.192.in-addr.arpa"),
|
||||
],
|
||||
)
|
||||
def test_ip4_reverse_zone_name(network, netmask, expected):
|
||||
subnet = Ip4Subnet(network=network, netmask=netmask)
|
||||
assert subnet.reverse_zone_name == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"network,netmask,expected",
|
||||
[
|
||||
("2001:db8", 64, "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"),
|
||||
("2001:db8", 48, "0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"),
|
||||
("2001:db8:abcd", 56, "0.0.d.c.b.a.8.b.d.0.1.0.0.2.ip6.arpa"),
|
||||
("2001:db8", 32, "8.b.d.0.1.0.0.2.ip6.arpa"),
|
||||
],
|
||||
)
|
||||
def test_ip6_reverse_zone_name(network, netmask, expected):
|
||||
subnet = Ip6Subnet(network=network, netmask=netmask)
|
||||
assert subnet.reverse_zone_name == expected
|
||||
@@ -0,0 +1,90 @@
|
||||
import pytest
|
||||
from django.contrib import messages
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
|
||||
from mdb.models import Host, Interface
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_fixtures(db):
|
||||
call_command("loaddata", "test_data", verbosity=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(db_with_fixtures):
|
||||
return Host.objects.first()
|
||||
|
||||
|
||||
def test_saving_installable_host_boots_it_from_the_network(host):
|
||||
Interface.objects.update(pxe_filename="", dhcp_client=False)
|
||||
host.save()
|
||||
|
||||
interface = host.interface_set.get()
|
||||
assert interface.pxe_filename == "pxelinux.0"
|
||||
assert interface.dhcp_client
|
||||
|
||||
|
||||
def test_saving_host_keeps_custom_pxe_filename(host):
|
||||
Interface.objects.update(pxe_filename="custom.efi")
|
||||
host.save()
|
||||
assert host.interface_set.get().pxe_filename == "custom.efi"
|
||||
|
||||
|
||||
def test_saving_non_installable_host_stops_netboot(host):
|
||||
Interface.objects.update(pxe_filename="pxelinux.0", dhcp_client=True)
|
||||
host.pxe_installable = False
|
||||
host.save()
|
||||
|
||||
interface = host.interface_set.get()
|
||||
assert interface.pxe_filename == ""
|
||||
# The host keeps its address.
|
||||
assert interface.dhcp_client
|
||||
|
||||
|
||||
def test_saving_non_installable_host_keeps_custom_pxe_filename(host):
|
||||
Interface.objects.update(pxe_filename="custom.efi")
|
||||
host.pxe_installable = False
|
||||
host.save()
|
||||
assert host.interface_set.get().pxe_filename == "custom.efi"
|
||||
|
||||
|
||||
def test_saving_host_without_key_generates_one(host):
|
||||
host.pxe_key = ""
|
||||
host.save()
|
||||
assert len(Host.objects.get(pk=host.pk).pxe_key) == 32
|
||||
|
||||
|
||||
def test_admin_marks_hosts_installable(host, admin_client):
|
||||
Host.objects.update(pxe_installable=False)
|
||||
Interface.objects.update(pxe_filename="")
|
||||
|
||||
res = admin_client.post(
|
||||
reverse("admin:mdb_host_changelist"),
|
||||
{"action": "set_installable", "_selected_action": [host.pk]},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert Host.objects.get(pk=host.pk).pxe_installable
|
||||
assert host.interface_set.get().pxe_filename == "pxelinux.0"
|
||||
assert not [m for m in res.context["messages"] if m.level == messages.WARNING]
|
||||
|
||||
|
||||
def test_admin_warns_about_interfaces_without_ip(host, admin_client):
|
||||
Host.objects.update(pxe_installable=False)
|
||||
Interface.objects.create(
|
||||
name="eth1", macaddr="00:11:22:33:44:55", host=host, domain_id=1
|
||||
)
|
||||
|
||||
res = admin_client.post(
|
||||
reverse("admin:mdb_host_changelist"),
|
||||
{"action": "set_installable", "_selected_action": [host.pk]},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
warnings = [str(m) for m in res.context["messages"] if m.level == messages.WARNING]
|
||||
assert warnings == [
|
||||
"Host tut might not be installable since interface(s) eth1 has no IP set."
|
||||
]
|
||||
assert Host.objects.get(pk=host.pk).pxe_installable
|
||||
@@ -0,0 +1,57 @@
|
||||
"""How mdbsite.settings loads production's settings, in a fresh interpreter each time,
|
||||
since the settings module is imported once per process."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
PRINT = (
|
||||
"import django, os; os.environ['DJANGO_SETTINGS_MODULE'] = 'mdbsite.settings'; "
|
||||
"django.setup(); from django.conf import settings as s; print(s.ALLOWED_HOSTS, s.DEBUG)"
|
||||
)
|
||||
|
||||
|
||||
def run(env):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", PRINT],
|
||||
cwd=ROOT,
|
||||
env={
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if k not in ("MDB_SETTINGS", "DJANGO_DEBUG")
|
||||
}
|
||||
| env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_settings_file_overrides(tmp_path):
|
||||
f = tmp_path / "settings.py"
|
||||
f.write_text("ALLOWED_HOSTS = ['mdb.example.org']\n")
|
||||
result = run({"MDB_SETTINGS": str(f)})
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "['mdb.example.org'] False"
|
||||
|
||||
|
||||
def test_failing_settings_file_stops_mdb(tmp_path):
|
||||
# As on sega after the 24.04 upgrade: python-ldap would not load, and the old
|
||||
# `except ImportError: pass` left MDB running on the defaults.
|
||||
f = tmp_path / "settings.py"
|
||||
f.write_text("import a_module_that_does_not_exist\n")
|
||||
result = run({"MDB_SETTINGS": str(f)})
|
||||
assert result.returncode != 0
|
||||
assert "a_module_that_does_not_exist" in result.stderr
|
||||
|
||||
|
||||
def test_missing_settings_file_stops_mdb(tmp_path):
|
||||
result = run({"MDB_SETTINGS": str(tmp_path / "missing.py")})
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
def test_debug_is_off_unless_asked():
|
||||
assert run({}).stdout.strip().endswith("False")
|
||||
assert run({"DJANGO_DEBUG": "1"}).stdout.strip().endswith("True")
|
||||
@@ -0,0 +1,86 @@
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mdb.models import DhcpConfig, Domain, Interface, Ip4Subnet, Ip6Subnet
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pre_save signals: populate domain_name once, then leave it frozen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_ip4subnet(**overrides):
|
||||
dhcp_config = DhcpConfig.objects.create(serial=1, active_serial=1)
|
||||
fields = {
|
||||
"name": "test-subnet",
|
||||
"netmask": "255.255.255.192",
|
||||
"network": "192.0.2.192",
|
||||
"domain_soa": "ns1.example.org",
|
||||
"domain_admin": "admin@example.org",
|
||||
"domain_filename": "db.test-subnet",
|
||||
"dhcp_config": dhcp_config,
|
||||
}
|
||||
fields.update(overrides)
|
||||
return Ip4Subnet.objects.create(**fields)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ip4_signal_sets_classless_domain_name():
|
||||
subnet = make_ip4subnet()
|
||||
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ip4_signal_sets_plain_domain_name_for_slash24():
|
||||
subnet = make_ip4subnet(network="203.0.113.0", netmask="255.255.255.0")
|
||||
assert subnet.domain_name == "113.0.203.in-addr.arpa"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ip4_signal_does_not_overwrite_existing_domain_name():
|
||||
subnet = make_ip4subnet(domain_name="manual.in-addr.arpa")
|
||||
assert subnet.domain_name == "manual.in-addr.arpa"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ip4_domain_name_is_frozen_when_network_changes():
|
||||
subnet = make_ip4subnet()
|
||||
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
|
||||
|
||||
subnet.network = "10.0.0.0"
|
||||
subnet.netmask = "255.255.255.0"
|
||||
subnet.save()
|
||||
subnet.refresh_from_db()
|
||||
|
||||
# The name is derived once and then left alone.
|
||||
assert subnet.domain_name == "192-26.2.0.192.in-addr.arpa"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ip6_signal_sets_domain_name():
|
||||
subnet = Ip6Subnet.objects.create(
|
||||
name="ipv6-doc",
|
||||
network="2001:db8",
|
||||
netmask=64,
|
||||
domain_soa="ns1.example.org",
|
||||
domain_admin="admin@example.org",
|
||||
domain_filename="db.reverse-v6",
|
||||
)
|
||||
assert subnet.domain_name == "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pre_delete Interface: the zones it was in get a new serial
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@freeze_time("2026-11-02")
|
||||
def test_deleting_interface_raises_zone_serials(domain):
|
||||
subnet = Interface.objects.get(pk=1).ip4address.subnet
|
||||
Domain.objects.filter(pk=domain.pk).update(domain_serial=2026110299)
|
||||
Ip4Subnet.objects.filter(pk=subnet.pk).update(domain_serial=2015010101)
|
||||
|
||||
Interface.objects.get(pk=1).delete()
|
||||
|
||||
# Past 99 the serial carries into the date instead of going back.
|
||||
assert Domain.objects.get(pk=domain.pk).domain_serial == 2026110300
|
||||
assert Ip4Subnet.objects.get(pk=subnet.pk).domain_serial == 2026110201
|
||||
@@ -0,0 +1,32 @@
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mdb.utils import format_domain_serial_and_add_one
|
||||
|
||||
|
||||
@freeze_time("2026-11-02")
|
||||
@pytest.mark.parametrize(
|
||||
("serial", "expected"),
|
||||
[
|
||||
(2026110205, 2026110206), # today: next number
|
||||
(2025010107, 2026110201), # an earlier day: today's first
|
||||
(1, 2026110201), # not a date at all
|
||||
(2026110298, 2026110299),
|
||||
(2026110299, 2026110300), # used to stay at 99, so the change never synced
|
||||
(2026110300, 2026110301), # ahead of today: never goes back
|
||||
(2026110505, 2026110506),
|
||||
(None, 2026110201),
|
||||
("garbage", 2026110201),
|
||||
],
|
||||
)
|
||||
def test_next_serial(serial, expected):
|
||||
assert format_domain_serial_and_add_one(serial) == expected
|
||||
|
||||
|
||||
@freeze_time("2026-11-02")
|
||||
def test_serial_always_increases():
|
||||
serial = 2026110290
|
||||
for _ in range(30):
|
||||
new = format_domain_serial_and_add_one(serial)
|
||||
assert new > serial
|
||||
serial = new
|
||||
@@ -0,0 +1,373 @@
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mdb.models import (
|
||||
DhcpConfig,
|
||||
DomainARecord,
|
||||
Host,
|
||||
HostType,
|
||||
Interface,
|
||||
Ip4Subnet,
|
||||
OperatingSystem,
|
||||
)
|
||||
from mdb.services.zone_sync import ZoneSyncService
|
||||
|
||||
EXPECTED_ZONE_FILE_CONTENTS = """\
|
||||
; serial:2024010101
|
||||
; zone file for example.org
|
||||
; 2024-01-01 00:00:00
|
||||
; filename: {filename}
|
||||
$TTL 3600
|
||||
@ IN SOA ns1.example.org. admin.example.org. (
|
||||
\t2024010101\t; serial
|
||||
\t28800\t; refresh
|
||||
\t7200\t; retry
|
||||
\t604800\t; expire
|
||||
\t86400 )\t; minimum ttl
|
||||
;
|
||||
@\tIN\tNS\tns1.example.org.
|
||||
@\tIN\tNS\tns2.example.org.
|
||||
@\t\tMX\t10 mail.example.org.
|
||||
@\tIN\tA\t1.2.3.4
|
||||
@\tIN\tAAAA\t2001:db8::1
|
||||
; SRV records
|
||||
; A records
|
||||
ns1 IN A 1.2.3.4
|
||||
ns2 IN A 5.6.7.8
|
||||
mail IN A 9.10.11.12
|
||||
www IN A 1.2.3.4
|
||||
; AAAA records
|
||||
www IN AAAA 2001:db8::1
|
||||
; CNAME records \nftp\tIN\tCNAME\twww.example.org.
|
||||
; TXT records \n@ TXT "v=spf1 mx ~all"
|
||||
; HOST records
|
||||
"""
|
||||
|
||||
# named-compilezone output from BIND 9.18, which the DNS server runs.
|
||||
# BIND 9.20 writes fully qualified names instead of relative ones.
|
||||
EXPECTED_NORMALIZED = (
|
||||
"$ORIGIN .\n"
|
||||
"$TTL 3600\t; 1 hour\n"
|
||||
"example.org\t\tIN SOA\tns1.example.org. admin.example.org. (\n"
|
||||
"\t\t\t\t2024010101 ; serial\n"
|
||||
"\t\t\t\t28800 ; refresh (8 hours)\n"
|
||||
"\t\t\t\t7200 ; retry (2 hours)\n"
|
||||
"\t\t\t\t604800 ; expire (1 week)\n"
|
||||
"\t\t\t\t86400 ; minimum (1 day)\n"
|
||||
"\t\t\t\t)\n"
|
||||
"\t\t\tNS\tns1.example.org.\n"
|
||||
"\t\t\tNS\tns2.example.org.\n"
|
||||
"\t\t\tA\t1.2.3.4\n"
|
||||
"\t\t\tMX\t10 mail.example.org.\n"
|
||||
'\t\t\tTXT\t"v=spf1 mx ~all"\n'
|
||||
"\t\t\tAAAA\t2001:db8::1\n"
|
||||
"$ORIGIN example.org.\n"
|
||||
"ftp\t\t\tCNAME\twww\n"
|
||||
"mail\t\t\tA\t9.10.11.12\n"
|
||||
"ns1\t\t\tA\t1.2.3.4\n"
|
||||
"ns2\t\t\tA\t5.6.7.8\n"
|
||||
"www\t\t\tA\t1.2.3.4\n"
|
||||
"\t\t\tAAAA\t2001:db8::1"
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_ZONE = """\
|
||||
$TTL 3600
|
||||
example.com. IN SOA ns1.example.com. admin.example.com. (
|
||||
2024010101 ; serial
|
||||
3600 ; refresh
|
||||
900 ; retry
|
||||
604800 ; expire
|
||||
86400 ; minimum
|
||||
)
|
||||
example.com. IN NS ns1.example.com.
|
||||
example.com. IN NS ns2.example.com.
|
||||
example.com. IN MX 10 mail.example.com.
|
||||
example.com. IN MX 20 mail2.example.com.
|
||||
example.com. IN A 1.2.3.4
|
||||
example.com. IN AAAA 2001:db8::1
|
||||
example.com. IN TXT "v=spf1 mx ~all"
|
||||
ns1.example.com. IN A 1.2.3.4
|
||||
ns2.example.com. IN A 5.6.7.8
|
||||
mail.example.com. IN A 9.10.11.12
|
||||
www.example.com. IN CNAME example.com.
|
||||
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
|
||||
"""
|
||||
|
||||
|
||||
def make_zone(domain_name="example.com", active_serial=2024010101, serial=2024010102):
|
||||
zone = MagicMock()
|
||||
zone.domain_name = domain_name
|
||||
zone.domain_active_serial = active_serial
|
||||
zone.domain_serial = serial
|
||||
return zone
|
||||
|
||||
|
||||
def test_calculate_diff_identical_returns_empty(service):
|
||||
zone = make_zone()
|
||||
assert service.calculate_diff("line\n", "line\n", zone) == ""
|
||||
|
||||
|
||||
def test_calculate_diff_format(service):
|
||||
zone = make_zone(domain_name="example.com", active_serial=1, serial=2)
|
||||
diff = service.calculate_diff("new line\n", "old line\n", zone)
|
||||
assert "*** 1-example.com" in diff
|
||||
assert "--- 2-example.com" in diff
|
||||
assert "! old line" in diff
|
||||
assert "! new line" in diff
|
||||
|
||||
|
||||
def test_normalize_zone_is_idempotent(service):
|
||||
zone = make_zone()
|
||||
normalized = service.normalize_zone(zone, SAMPLE_ZONE)
|
||||
assert service.normalize_zone(zone, normalized) == normalized
|
||||
|
||||
|
||||
def test_normalize_zone_fallback_when_binary_missing(service):
|
||||
service.compilezone_bin = "/nonexistent/named-compilezone"
|
||||
zone = make_zone()
|
||||
assert service.normalize_zone(zone, SAMPLE_ZONE) == SAMPLE_ZONE
|
||||
|
||||
|
||||
def test_check_zone_valid(service):
|
||||
zone = make_zone()
|
||||
status, _ = service.check_zone(zone, SAMPLE_ZONE)
|
||||
assert status == 0
|
||||
|
||||
|
||||
def test_check_zone_invalid(service):
|
||||
zone = make_zone()
|
||||
status, _ = service.check_zone(zone, "this is not a valid zone file")
|
||||
assert status != 0
|
||||
|
||||
|
||||
@freeze_time("2024-01-01 00:00:00")
|
||||
def test_zone_file_contents_includes_all_record_types(db_domain):
|
||||
content = db_domain.zone_file_contents()
|
||||
assert content == EXPECTED_ZONE_FILE_CONTENTS.format(
|
||||
filename=db_domain.domain_filename
|
||||
)
|
||||
|
||||
|
||||
def bind_version():
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[ZoneSyncService.compilezone_bin, "-v"], capture_output=True, text=True
|
||||
).stdout
|
||||
except OSError:
|
||||
return None
|
||||
return out.strip()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (bind_version() or "").startswith("9.18."),
|
||||
reason="expected output is from BIND 9.18, which the DNS server runs",
|
||||
)
|
||||
def test_zone_file_contents_normalizes_without_error(db_domain, service):
|
||||
normalized = service.normalize_zone(db_domain, db_domain.zone_file_contents())
|
||||
assert normalized == EXPECTED_NORMALIZED
|
||||
|
||||
|
||||
def test_check_zone_accepts_zone_file_contents(db_domain, service):
|
||||
content = service.normalize_zone(db_domain, db_domain.zone_file_contents())
|
||||
status, output = service.check_zone(db_domain, content)
|
||||
assert status == 0, output
|
||||
assert "loaded serial 2024010101" in output
|
||||
assert output.endswith("OK")
|
||||
|
||||
|
||||
def test_read_serial_from_normalized_zone():
|
||||
assert ZoneSyncService().read_serial(EXPECTED_NORMALIZED) == 2024010101
|
||||
|
||||
|
||||
def test_read_serial_from_generated_zone():
|
||||
assert ZoneSyncService().read_serial(SAMPLE_ZONE) == 2024010101
|
||||
|
||||
|
||||
def test_read_serial_none_without_soa():
|
||||
assert ZoneSyncService().read_serial("") is None
|
||||
|
||||
|
||||
def test_pending_zones_skips_synced_zones(db_domain, service):
|
||||
assert service.pending_zones() == [db_domain]
|
||||
db_domain.domain_active_serial = db_domain.domain_serial
|
||||
db_domain.save()
|
||||
assert service.pending_zones() == []
|
||||
service.force = True
|
||||
assert service.pending_zones() == [db_domain]
|
||||
|
||||
|
||||
def test_find_zone_returns_matching_domain(db_domain, service):
|
||||
assert service.find_zone("example.org") == db_domain
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_find_zone_returns_none_for_unknown(service):
|
||||
assert service.find_zone("nonexistent.example") is None
|
||||
|
||||
|
||||
def test_sync_zone_writes_zone_file_and_updates_serial(db_domain, tmp_path):
|
||||
svc = ZoneSyncService(debug=False)
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
|
||||
svc.sync_zone(db_domain)
|
||||
|
||||
assert os.path.exists(db_domain.domain_filename)
|
||||
db_domain.refresh_from_db()
|
||||
assert db_domain.domain_active_serial == db_domain.domain_serial
|
||||
|
||||
|
||||
def test_sync_zone_records_success_and_diff(db_domain, tmp_path):
|
||||
svc = ZoneSyncService(debug=False)
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
|
||||
svc.sync_zone(db_domain)
|
||||
|
||||
change = svc.changes["example.org"]
|
||||
assert change["success"] is True
|
||||
assert change["errors"] is None
|
||||
assert change["old_serial"] == 0
|
||||
assert change["new_serial"] == 2024010101
|
||||
|
||||
|
||||
def test_sync_zone_raises_serial_past_live_zone(db_domain, tmp_path):
|
||||
"""Dynamic updates can leave the live serial above the one mdb generated."""
|
||||
with open(db_domain.domain_filename, "w") as f:
|
||||
f.write(EXPECTED_NORMALIZED.replace("2024010101", "2024010137"))
|
||||
svc = ZoneSyncService(debug=False)
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
|
||||
svc.sync_zone(db_domain)
|
||||
|
||||
db_domain.refresh_from_db()
|
||||
assert db_domain.domain_serial == 2024010138
|
||||
assert db_domain.domain_active_serial == 2024010138
|
||||
assert svc.changes["example.org"]["new_serial"] == 2024010138
|
||||
with open(db_domain.domain_filename) as f:
|
||||
assert svc.read_serial(f.read()) == 2024010138
|
||||
|
||||
|
||||
def test_sync_zone_keeps_serial_above_live_zone(db_domain, tmp_path):
|
||||
with open(db_domain.domain_filename, "w") as f:
|
||||
f.write(EXPECTED_NORMALIZED.replace("2024010101", "2024010099"))
|
||||
svc = ZoneSyncService(debug=False)
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
|
||||
svc.sync_zone(db_domain)
|
||||
|
||||
db_domain.refresh_from_db()
|
||||
assert db_domain.domain_serial == 2024010101
|
||||
|
||||
|
||||
def add_host(domain, hostname, allow_email, ip4address=None):
|
||||
host_type = HostType.objects.create(host_type="server", description="")
|
||||
operating_system = OperatingSystem.objects.create(
|
||||
name="Debian", version="12", arch="x86_64"
|
||||
)
|
||||
host = Host.objects.create(
|
||||
hostname=hostname,
|
||||
host_type=host_type,
|
||||
operating_system=operating_system,
|
||||
allow_email=allow_email,
|
||||
)
|
||||
Interface.objects.create(
|
||||
name="eth0",
|
||||
macaddr="00:11:22:33:44:55",
|
||||
host=host,
|
||||
domain=domain,
|
||||
ip4address=ip4address,
|
||||
)
|
||||
# Creating the interface bumps the domain serial via a signal; reload so the
|
||||
# in-memory domain stays consistent with the DB.
|
||||
domain.refresh_from_db()
|
||||
return host
|
||||
|
||||
|
||||
def make_ip4address():
|
||||
"""Create a tiny (/30) subnet; its post_save signal generates the host IPs."""
|
||||
dhcp_config = DhcpConfig.objects.create(serial=1, active_serial=1)
|
||||
subnet = Ip4Subnet.objects.create(
|
||||
name="test-subnet",
|
||||
netmask="255.255.255.252",
|
||||
network="10.0.0.0",
|
||||
domain_soa="ns1.example.org",
|
||||
domain_admin="admin@example.org",
|
||||
domain_filename="db.test-subnet",
|
||||
dhcp_config=dhcp_config,
|
||||
)
|
||||
return subnet.ip4address_set.get(address="10.0.0.1")
|
||||
|
||||
|
||||
def test_zone_file_contents_includes_spf_record_when_email_allowed(db_domain):
|
||||
add_host(db_domain, "mailhost", allow_email=True)
|
||||
content = db_domain.zone_file_contents()
|
||||
assert 'mailhost\tTXT\t"v=spf1 mx -all"\n' in content
|
||||
|
||||
|
||||
def test_zone_file_contents_omits_spf_record_when_email_not_allowed(db_domain):
|
||||
add_host(db_domain, "plainhost", allow_email=False)
|
||||
content = db_domain.zone_file_contents()
|
||||
assert "v=spf1 mx -all" not in content
|
||||
|
||||
|
||||
def test_zone_file_contents_includes_ip4_in_spf_record_when_interface_has_address(
|
||||
db_domain,
|
||||
):
|
||||
address = make_ip4address()
|
||||
add_host(db_domain, "mailhost", allow_email=True, ip4address=address)
|
||||
content = db_domain.zone_file_contents()
|
||||
assert f'mailhost\tTXT\t"v=spf1 mx ip4:{address.address} -all"\n' in content
|
||||
|
||||
|
||||
def test_sync_zone_does_not_write_a_zone_that_fails_validation(db_domain, tmp_path):
|
||||
with open(db_domain.domain_filename, "w") as f:
|
||||
f.write("the live zone\n")
|
||||
DomainARecord.objects.create(domain=db_domain, name="bad", target="not-an-address")
|
||||
svc = ZoneSyncService(debug=False)
|
||||
svc.checkzone_dir = str(tmp_path)
|
||||
|
||||
assert svc.sync_zone(db_domain) is False
|
||||
|
||||
with open(db_domain.domain_filename) as f:
|
||||
assert f.read() == "the live zone\n"
|
||||
db_domain.refresh_from_db()
|
||||
assert db_domain.domain_active_serial == 0
|
||||
change = svc.changes["example.org"]
|
||||
assert change["success"] is False
|
||||
assert "not-an-address" in change["errors"]
|
||||
|
||||
|
||||
def test_write_zone_file_replaces_file_and_keeps_its_mode(service, tmp_path):
|
||||
path = tmp_path / "db.example.org"
|
||||
path.write_text("old\n")
|
||||
path.chmod(0o664)
|
||||
inode = path.stat().st_ino
|
||||
|
||||
service.write_zone_file(str(path), "new\n")
|
||||
|
||||
assert path.read_text() == "new\n"
|
||||
assert path.stat().st_mode & 0o777 == 0o664
|
||||
assert path.stat().st_ino != inode # renamed over, not written in place
|
||||
assert os.listdir(tmp_path) == ["db.example.org"]
|
||||
|
||||
|
||||
def test_write_zone_file_creates_missing_file(service, tmp_path):
|
||||
path = tmp_path / "db.example.org"
|
||||
service.write_zone_file(str(path), "new\n")
|
||||
assert path.read_text() == "new\n"
|
||||
assert path.stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
def test_write_zone_file_leaves_old_file_when_write_fails(service, tmp_path):
|
||||
path = tmp_path / "db.example.org"
|
||||
path.write_text("old\n")
|
||||
|
||||
with patch("os.fsync", side_effect=OSError("disk full")), pytest.raises(OSError):
|
||||
service.write_zone_file(str(path), "new\n")
|
||||
|
||||
assert path.read_text() == "old\n"
|
||||
assert os.listdir(tmp_path) == ["db.example.org"]
|
||||
@@ -0,0 +1,369 @@
|
||||
import os
|
||||
import signal
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mdb.management.commands.zone_sync import Command as CommandClass
|
||||
from mdb.services.zone_sync import ZoneSyncService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def checkzone_dir(tmp_path, monkeypatch):
|
||||
"""Where the frozen marker lives; not the real /tmp/mdb-checkzone."""
|
||||
monkeypatch.setattr(ZoneSyncService, "checkzone_dir", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmd():
|
||||
c = CommandClass()
|
||||
c.stdout = StringIO()
|
||||
c.stderr = StringIO()
|
||||
return c
|
||||
|
||||
|
||||
def make_service(changes=None):
|
||||
svc = MagicMock()
|
||||
svc.changes = changes or {
|
||||
"example.org": {"errors": None, "diff": "- old\n+ new", "success": True}
|
||||
}
|
||||
svc.reload.return_value = (True, "", "")
|
||||
return svc
|
||||
|
||||
|
||||
# --- run_commands, freeze, reload ---
|
||||
|
||||
|
||||
def test_run_commands_calls_all_on_success(service):
|
||||
with patch("mdb.services.zone_sync.getstatusoutput", return_value=(0, "")) as mock:
|
||||
success, _, _ = service.run_commands(["a", "b", "c"])
|
||||
assert success is True
|
||||
assert mock.call_count == 3
|
||||
|
||||
|
||||
def test_run_commands_stops_on_first_failure(service):
|
||||
with patch(
|
||||
"mdb.services.zone_sync.getstatusoutput",
|
||||
side_effect=[(0, ""), (1, "connection refused"), (0, "")],
|
||||
) as mock:
|
||||
success, failed_cmd, output = service.run_commands(["a", "b", "c"])
|
||||
assert success is False
|
||||
assert failed_cmd == "b"
|
||||
assert output == "connection refused"
|
||||
assert mock.call_count == 2
|
||||
|
||||
|
||||
def test_freeze_runs_only_freeze(service):
|
||||
with patch("mdb.services.zone_sync.getstatusoutput", return_value=(0, "")) as mock:
|
||||
service.freeze()
|
||||
assert [c.args[0] for c in mock.call_args_list] == [service.freeze_command]
|
||||
|
||||
|
||||
def test_reload_runs_reload_then_thaw(service):
|
||||
with patch("mdb.services.zone_sync.getstatusoutput", return_value=(0, "")) as mock:
|
||||
service.reload()
|
||||
assert [c.args[0] for c in mock.call_args_list] == [
|
||||
service.reload_command,
|
||||
service.thaw_command,
|
||||
]
|
||||
|
||||
|
||||
# --- reload_and_notify ---
|
||||
|
||||
|
||||
def test_reload_and_notify_success_writes_ok(cmd):
|
||||
svc = make_service()
|
||||
with patch("mdb.management.commands.zone_sync.discord"):
|
||||
cmd.reload_and_notify(svc, svc.changes)
|
||||
assert "ok" in cmd.stdout.getvalue()
|
||||
|
||||
|
||||
def test_reload_and_notify_success_calls_discord_once_per_zone(cmd):
|
||||
changes = {
|
||||
"example.org": {"errors": None, "diff": "- old\n+ new"},
|
||||
"other.net": {"errors": None, "diff": "- x\n+ y"},
|
||||
}
|
||||
svc = make_service(changes=changes)
|
||||
with patch("mdb.management.commands.zone_sync.discord") as mock_discord:
|
||||
cmd.reload_and_notify(svc, svc.changes)
|
||||
assert mock_discord.notify.call_count == 2
|
||||
subjects = [c.kwargs["subject"] for c in mock_discord.notify.call_args_list]
|
||||
messages = [c.kwargs["message"] for c in mock_discord.notify.call_args_list]
|
||||
assert all("✅" in s for s in subjects)
|
||||
assert any("`example.org`" in m for m in messages)
|
||||
assert any("`other.net`" in m for m in messages)
|
||||
|
||||
|
||||
def test_reload_and_notify_success_passes_diff_as_preformatted(cmd):
|
||||
svc = make_service()
|
||||
with patch("mdb.management.commands.zone_sync.discord") as mock_discord:
|
||||
cmd.reload_and_notify(svc, svc.changes)
|
||||
assert mock_discord.notify.call_args.kwargs["preformatted"] == "- old\n+ new"
|
||||
|
||||
|
||||
def test_reload_and_notify_failure_writes_fail_and_exits(cmd):
|
||||
svc = make_service()
|
||||
svc.reload.return_value = (False, "/usr/sbin/rndc reload", "timed out")
|
||||
with patch("mdb.management.commands.zone_sync.discord"):
|
||||
with patch("getpass.getuser", return_value="testuser"):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cmd.reload_and_notify(svc, svc.changes)
|
||||
assert exc.value.code == 1
|
||||
assert "fail" in cmd.stdout.getvalue()
|
||||
|
||||
|
||||
def test_reload_and_notify_failure_discord_includes_command_and_output(cmd):
|
||||
svc = make_service()
|
||||
svc.reload.return_value = (False, "/usr/sbin/rndc reload", "timed out")
|
||||
with patch("mdb.management.commands.zone_sync.discord") as mock_discord:
|
||||
with patch("getpass.getuser", return_value="testuser"):
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.reload_and_notify(svc, svc.changes)
|
||||
subject = mock_discord.notify.call_args.kwargs["subject"]
|
||||
message = mock_discord.notify.call_args.kwargs["message"]
|
||||
assert "⚠️" in subject
|
||||
assert "rndc reload" in message
|
||||
assert "timed out" in message
|
||||
assert "- example.org" in message
|
||||
|
||||
|
||||
# --- handle ---
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_exits_if_zone_not_found(cmd):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cmd.handle(debug=True, force=False, zone="nonexistent.example")
|
||||
assert exc.value.code == 1
|
||||
assert "nonexistent.example" in cmd.stderr.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_skips_reload_in_debug_mode(cmd, db_domain):
|
||||
with patch.object(cmd, "reload_and_notify") as mock_reload:
|
||||
cmd.handle(debug=True, force=True, zone="example.org")
|
||||
mock_reload.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_calls_reload_and_notify_when_changes_exist(cmd, db_domain):
|
||||
with (
|
||||
patch.object(cmd, "freeze"),
|
||||
patch.object(cmd, "reload_and_notify") as mock_reload,
|
||||
):
|
||||
cmd.handle(debug=False, force=True, zone="example.org")
|
||||
mock_reload.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_does_nothing_without_pending_zones(cmd):
|
||||
with patch.object(ZoneSyncService, "freeze") as mock_freeze:
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
mock_freeze.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_freezes_before_writing_and_reloads_after(cmd, db_domain):
|
||||
"""rndc freeze dumps the in-memory zone, so it must not run after the write."""
|
||||
seen = []
|
||||
|
||||
def freeze():
|
||||
seen.append(("freeze", os.path.exists(db_domain.domain_filename)))
|
||||
return True, "", ""
|
||||
|
||||
def reload():
|
||||
seen.append(("reload", os.path.exists(db_domain.domain_filename)))
|
||||
return True, "", ""
|
||||
|
||||
with (
|
||||
patch.object(ZoneSyncService, "freeze", side_effect=freeze),
|
||||
patch.object(ZoneSyncService, "reload", side_effect=reload),
|
||||
patch("mdb.management.commands.zone_sync.discord"),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert seen == [("freeze", False), ("reload", True)]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_writes_nothing_when_freeze_fails(cmd, db_domain):
|
||||
with (
|
||||
patch.object(
|
||||
ZoneSyncService, "freeze", return_value=(False, "rndc freeze", "no")
|
||||
),
|
||||
patch.object(ZoneSyncService, "thaw", return_value=(True, "", "")) as thaw,
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
patch("getpass.getuser", return_value="testuser"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert not os.path.exists(db_domain.domain_filename)
|
||||
thaw.assert_called_once()
|
||||
assert "- example.org" in mock_discord.notify.call_args.kwargs["message"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_thaws_when_sync_raises(cmd, db_domain):
|
||||
with (
|
||||
patch.object(ZoneSyncService, "freeze", return_value=(True, "", "")),
|
||||
patch.object(
|
||||
ZoneSyncService, "reload", return_value=(True, "", "")
|
||||
) as mock_reload,
|
||||
patch.object(ZoneSyncService, "sync_zone", side_effect=RuntimeError),
|
||||
patch("mdb.management.commands.zone_sync.discord"),
|
||||
pytest.raises(RuntimeError),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
mock_reload.assert_called_once()
|
||||
|
||||
|
||||
# --- stopped and killed syncs ---
|
||||
|
||||
|
||||
def rndc(results=None):
|
||||
"""Patch rndc: every command succeeds unless results maps it to (status, output)."""
|
||||
results = results or {}
|
||||
calls = []
|
||||
|
||||
def run(command):
|
||||
calls.append(command.rsplit(" ", 1)[-1])
|
||||
return results.get(command, (0, ""))
|
||||
|
||||
return calls, patch("mdb.services.zone_sync.getstatusoutput", side_effect=run)
|
||||
|
||||
|
||||
def test_reload_thaws_even_when_reload_fails(service):
|
||||
calls, rndc_patch = rndc({ZoneSyncService.reload_command: (1, "timed out")})
|
||||
with rndc_patch:
|
||||
success, failed_command, output = service.reload()
|
||||
assert calls == ["reload", "thaw"]
|
||||
assert (success, failed_command, output) == (
|
||||
False,
|
||||
ZoneSyncService.reload_command,
|
||||
"timed out",
|
||||
)
|
||||
|
||||
|
||||
def test_marker_exists_only_while_frozen(service):
|
||||
calls, rndc_patch = rndc()
|
||||
with rndc_patch:
|
||||
service.freeze()
|
||||
assert service.frozen_since() is not None
|
||||
service.reload()
|
||||
assert service.frozen_since() is None
|
||||
|
||||
|
||||
def test_marker_stays_when_thaw_fails(service):
|
||||
calls, rndc_patch = rndc({ZoneSyncService.thaw_command: (1, "no")})
|
||||
with rndc_patch:
|
||||
service.freeze()
|
||||
service.reload()
|
||||
assert service.frozen_since() is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("signum", [signal.SIGHUP, signal.SIGTERM, signal.SIGINT])
|
||||
def test_handle_thaws_and_warns_when_stopped_by_signal(cmd, db_domain, signum):
|
||||
def killed(zone):
|
||||
os.kill(os.getpid(), signum)
|
||||
|
||||
calls, rndc_patch = rndc()
|
||||
previous = signal.getsignal(signum)
|
||||
with (
|
||||
rndc_patch,
|
||||
patch.object(ZoneSyncService, "sync_zone", side_effect=killed),
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert exc.value.code == 128 + signum
|
||||
assert calls == ["freeze", "reload", "thaw"]
|
||||
assert ZoneSyncService().frozen_since() is None
|
||||
kwargs = mock_discord.notify.call_args.kwargs
|
||||
assert kwargs["subject"] == "⚠️ Zone sync interrupted, zones thawed"
|
||||
assert signal.Signals(signum).name in kwargs["message"]
|
||||
assert "Not written, run mdb-sync again:\n- example.org" in kwargs["message"]
|
||||
assert signal.getsignal(signum) == previous
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_ignores_signals_while_rndc_runs(cmd, db_domain):
|
||||
seen = []
|
||||
|
||||
def run(commands):
|
||||
seen.append((commands, signal.getsignal(signal.SIGHUP)))
|
||||
return True, "", ""
|
||||
|
||||
with (
|
||||
patch.object(ZoneSyncService, "run_commands", side_effect=run),
|
||||
patch("mdb.management.commands.zone_sync.discord"),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
# Signals still stop named-checkzone and the writes, between freeze and reload.
|
||||
assert seen == [
|
||||
([ZoneSyncService.freeze_command], signal.SIG_IGN),
|
||||
([ZoneSyncService.reload_command], signal.SIG_IGN),
|
||||
([ZoneSyncService.thaw_command], signal.SIG_IGN),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_warns_when_reload_after_signal_fails(cmd, db_domain):
|
||||
calls, rndc_patch = rndc({ZoneSyncService.thaw_command: (1, "no")})
|
||||
with (
|
||||
rndc_patch,
|
||||
patch.object(
|
||||
ZoneSyncService,
|
||||
"sync_zone",
|
||||
side_effect=lambda zone: os.kill(os.getpid(), signal.SIGHUP),
|
||||
),
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
patch("getpass.getuser", return_value="testuser"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
kwargs = mock_discord.notify.call_args.kwargs
|
||||
assert kwargs["subject"] == "⚠️ FAIL: bind"
|
||||
assert "may still be frozen" in kwargs["message"]
|
||||
assert ZoneSyncService().frozen_since() is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_thaws_zones_a_killed_sync_left_frozen(cmd, checkzone_dir):
|
||||
(checkzone_dir / "zones-frozen").write_text("1234\n")
|
||||
calls, rndc_patch = rndc()
|
||||
with (
|
||||
rndc_patch,
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert calls == ["reload", "thaw"]
|
||||
assert ZoneSyncService().frozen_since() is None
|
||||
kwargs = mock_discord.notify.call_args.kwargs
|
||||
assert kwargs["subject"] == "⚠️ Zones thawed after a killed zone sync"
|
||||
assert "left every zone frozen" in kwargs["message"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_stops_when_thawing_a_killed_sync_fails(cmd, db_domain, checkzone_dir):
|
||||
(checkzone_dir / "zones-frozen").write_text("1234\n")
|
||||
calls, rndc_patch = rndc({ZoneSyncService.reload_command: (1, "no")})
|
||||
with (
|
||||
rndc_patch,
|
||||
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
|
||||
patch("getpass.getuser", return_value="testuser"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd.handle(debug=False, force=False, zone=None)
|
||||
|
||||
assert calls == ["reload", "thaw"] # nothing frozen again, nothing written
|
||||
assert not os.path.exists(db_domain.domain_filename)
|
||||
assert "thawing them failed" in mock_discord.notify.call_args.kwargs["message"]
|
||||
@@ -0,0 +1,415 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = "==3.14.*"
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/90/f5fce14e95fccf53b58e8e2c47cd84d40d105d64073ae1ac75166db150e0/asgiref-3.13.0.tar.gz", hash = "sha256:034cc8cbf72d00553b4be256703ac545120b4e50af13b49dec99da899aeeecb4", size = 52941, upload-time = "2026-10-23T08:13:14.715Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b0/ec211e1f7966247cb6469158d856bac9aa4c2fe13621bd6a352e4f850453/asgiref-3.13.0-py3-none-any.whl", hash = "sha256:f9596a2434512686473d50f96ff6207fbba49ae1a785dfac614df4aeb085674d", size = 27593, upload-time = "2026-10-23T08:13:13.394Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.10.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "6.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/24/20d187a22ac821288b53c53b8f696019dcb0c7b4f973f052ade19392c74d/django-6.1.2.tar.gz", hash = "sha256:a1e92451ccb8b514e91bbb3b6d186d20b4030558f116b5d9de6535455ff210b7", size = 11251425, upload-time = "2026-10-06T12:53:29.622Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/83/90ff2dbfac7b519ed77620bdbb827cd2d1a83faf00d076bf95056428bce7/django-6.1.2-py3-none-any.whl", hash = "sha256:141efee6ec64d1db6db90683bf734c550102450f444fb099063b0be1bd27d991", size = 8420684, upload-time = "2026-10-06T12:53:17.381Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-auth-ldap"
|
||||
version = "5.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "python-ldap" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/3a/ff3c0ac9cd315a4b872b0c913463643ce2df1306b10db1e054136edf579a/django_auth_ldap-5.4.0.tar.gz", hash = "sha256:66740dbfba0f41ea6c030bc53974787cbc2f27ed1f3796ebf9fe0cbc9917605e", size = 56416, upload-time = "2026-10-20T21:29:07.937Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/95/93bf708f2cba96e5c2ab6aabc3694c7c3dbdd25df1461af2e16a09d1bce8/django_auth_ldap-5.4.0-py3-none-any.whl", hash = "sha256:8a8e38add49be404e885e94216ff8e7611e11e55bf6cb6576c3b560a6a25dc20", size = 20893, upload-time = "2026-10-20T21:29:06.383Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-extensions"
|
||||
version = "4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.18.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/b2/1cee76b188301f7d03169ca77fab2b3d3ec5d01b3469189f944af2d7f11a/djangorestframework-3.18.3.tar.gz", hash = "sha256:446a9b352e7eff630421ab3f2328bd2401b109a9470afa4a31189994911ed030", size = 923563, upload-time = "2026-10-06T21:33:51.521Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/dd/de5f4543765bec94e036a5e9593a42f8fa7efec8de78eb56b9ca834385f3/djangorestframework-3.18.3-py3-none-any.whl", hash = "sha256:8544bb674846731b1e3c9b309236ee1dc412905a0aa725be2ec193ca950a7d12", size = 903986, upload-time = "2026-10-06T21:33:49.917Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freezegun"
|
||||
version = "1.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "26.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdb"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
default-groups = ["dev", "prod"]
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-extensions" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "requests" },
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "whitenoise" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "freezegun" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
prod = [
|
||||
{ name = "django-auth-ldap" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = ">=6.1.2" },
|
||||
{ name = "django-extensions", specifier = ">=4.1" },
|
||||
{ name = "djangorestframework", specifier = ">=3.18.3" },
|
||||
{ name = "gunicorn", specifier = ">=26.2.0" },
|
||||
{ name = "requests", specifier = ">=2.34.2" },
|
||||
{ name = "sentry-sdk", specifier = ">=2.73.0" },
|
||||
{ name = "whitenoise", specifier = ">=6.12,<7" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "freezegun", specifier = ">=1.5.5" },
|
||||
{ name = "pytest", specifier = ">=9.2.0" },
|
||||
{ name = "pytest-django", specifier = ">=4.14.0" },
|
||||
{ name = "ruff", specifier = ">=0.17.3,<1.0.0" },
|
||||
]
|
||||
prod = [{ name = "django-auth-ldap", specifier = ">=5.4.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/7e/3c0da41061a2021cac7cd524fd57092adbe5b515e8bf9005702e366802e2/pyasn1-0.6.5.tar.gz", hash = "sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47", size = 155152, upload-time = "2026-10-27T06:32:49.125Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1f/3eefd5c84c35cae44549ce1c7f7e58f7945b34f5a6bce07a135438522073/pyasn1-0.6.5-py3-none-any.whl", hash = "sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587", size = 84807, upload-time = "2026-10-27T06:32:48.045Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-django"
|
||||
version = "4.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/39/b3927f3616f6223832f9b760e4208e8a7c7e1dea2b4d9ad7d32a5d214c3a/python_dateutil-4.0.0.tar.gz", hash = "sha256:d7295ff14fdc0f92f662100bfb6c551d160ca090f2939f1ec5b9794265c03f06", size = 250419, upload-time = "2026-11-01T07:02:42.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/a9/52b89563d154433d79b51ed1eb4842bf7334cb2696891a7fe135618853d7/python_dateutil-4.0.0-py3-none-any.whl", hash = "sha256:a5e246f79eb80475e691bf93862cead98decd8a498cf53ba0516fcdc0f50dc1c", size = 92853, upload-time = "2026-11-01T07:02:40.797Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-ldap"
|
||||
version = "3.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
{ name = "pyasn1-modules" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/45/489fcf46984f916ef165cdc6a2a1f1ca01ab6f486d47169256ebe44bdd62/python_ldap-3.4.8.tar.gz", hash = "sha256:18dc7460470c6ff64ed5c04ee21c56dbfee7ab433a53213ba91e407eea44c34c", size = 387710, upload-time = "2026-09-16T12:41:20.15Z" }
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.17.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.73.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/3b/1e47acf2456781c26e12007379ecc154bf310b8f64d40390d78b791d55c3/sentry_sdk-2.73.0.tar.gz", hash = "sha256:4a2ab97b16e4693c325ab361c7bbbb346dbc7d0bc0150922d795a25bac8d5280", size = 1079078, upload-time = "2026-10-22T09:08:49.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/88/cabf98d017d7b940720e514e5f8ee02e3e6f7495caf93c30735a38387ec4/sentry_sdk-2.73.0-py3-none-any.whl", hash = "sha256:0bcfa6928ea5adbb89591858b514d27adac27c7a3cccc31b20ed10e0b09eda9e", size = 549011, upload-time = "2026-10-22T09:08:47.88Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/e0/361c764ea58cc1a75f7b4b424a74b08449db8bfa5e9199c7275999fdd1b9/sqlparse-0.6.1.tar.gz", hash = "sha256:ca722573f848ff2d30e40ea30f53a104b7dea7635ea9285891263eb2c58fb6e7", size = 182236, upload-time = "2026-11-01T18:06:45.951Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/ff/2fee788e82e75be8f210c65c5d103bf44c880e3769c884652a4cd911b8ba/sqlparse-0.6.1-py3-none-any.whl", hash = "sha256:3be5a72d04c873d690d7c6dedd33d6fae2ef82759881ceec02fc2efd0cba564f", size = 51200, upload-time = "2026-11-01T18:06:47.434Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whitenoise"
|
||||
version = "6.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
|
||||
]
|
||||
Reference in new issue
Block a user