Merge pull request #327 from eReuse/feature/3598-binding

Feature/3598 binding
This commit is contained in:
cayop 2022-08-09 11:41:26 +02:00 committed by GitHub
commit 00f03cb082
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 230 additions and 58 deletions

View File

@ -317,6 +317,7 @@ class NewDeviceForm(FlaskForm):
id_device_supplier = StringField('Id Supplier', [validators.Optional()]) id_device_supplier = StringField('Id Supplier', [validators.Optional()])
phid = StringField('Placeholder Hardware identity (Phid)', [validators.Optional()]) phid = StringField('Placeholder Hardware identity (Phid)', [validators.Optional()])
pallet = StringField('Identity of pallet', [validators.Optional()]) pallet = StringField('Identity of pallet', [validators.Optional()])
components = TextAreaField('Components', [validators.Optional()])
info = TextAreaField('Info', [validators.Optional()]) info = TextAreaField('Info', [validators.Optional()])
serial_number = StringField('Seria Number', [validators.Optional()]) serial_number = StringField('Seria Number', [validators.Optional()])
model = StringField('Model', [validators.Optional()]) model = StringField('Model', [validators.Optional()])
@ -392,6 +393,7 @@ class NewDeviceForm(FlaskForm):
self.phid.data = self._obj.placeholder.phid self.phid.data = self._obj.placeholder.phid
self.pallet.data = self._obj.placeholder.pallet self.pallet.data = self._obj.placeholder.pallet
self.info.data = self._obj.placeholder.info self.info.data = self._obj.placeholder.info
self.components.data = self._obj.placeholder.components
self.serial_number.data = self._obj.serial_number self.serial_number.data = self._obj.serial_number
self.model.data = self._obj.model self.model.data = self._obj.model
self.manufacturer.data = self._obj.manufacturer self.manufacturer.data = self._obj.manufacturer
@ -414,6 +416,35 @@ class NewDeviceForm(FlaskForm):
self.resolution.data = self._obj.resolution_width self.resolution.data = self._obj.resolution_width
self.screen.data = self._obj.size self.screen.data = self._obj.size
if self._obj.placeholder.is_abstract:
self.type.render_kw = disabled
self.amount.render_kw = disabled
self.id_device_supplier.render_kw = disabled
self.pallet.render_kw = disabled
self.info.render_kw = disabled
self.components.render_kw = disabled
self.serial_number.render_kw = disabled
self.model.render_kw = disabled
self.manufacturer.render_kw = disabled
self.appearance.render_kw = disabled
self.functionality.render_kw = disabled
self.brand.render_kw = disabled
self.generation.render_kw = disabled
self.version.render_kw = disabled
self.weight.render_kw = disabled
self.width.render_kw = disabled
self.height.render_kw = disabled
self.depth.render_kw = disabled
self.variant.render_kw = disabled
self.sku.render_kw = disabled
self.image.render_kw = disabled
if self._obj.type in ['Smartphone', 'Tablet', 'Cellphone']:
self.imei.render_kw = disabled
self.meid.render_kw = disabled
if self._obj.type == 'ComputerMonitor':
self.resolution.render_kw = disabled
self.screen.render_kw = disabled
def validate(self, extra_validators=None): # noqa: C901 def validate(self, extra_validators=None): # noqa: C901
error = ["Not a correct value"] error = ["Not a correct value"]
is_valid = super().validate(extra_validators) is_valid = super().validate(extra_validators)
@ -567,6 +598,7 @@ class NewDeviceForm(FlaskForm):
'phid': self.phid.data or None, 'phid': self.phid.data or None,
'id_device_supplier': self.id_device_supplier.data, 'id_device_supplier': self.id_device_supplier.data,
'info': self.info.data, 'info': self.info.data,
'components': self.components.data,
'pallet': self.pallet.data, 'pallet': self.pallet.data,
'is_abstract': False, 'is_abstract': False,
} }
@ -575,10 +607,13 @@ class NewDeviceForm(FlaskForm):
def edit_device(self): def edit_device(self):
self._obj.placeholder.phid = self.phid.data or self._obj.placeholder.phid self._obj.placeholder.phid = self.phid.data or self._obj.placeholder.phid
self._obj.placeholder.id_device_supplier = self.id_device_supplier.data or None if not self._obj.placeholder.is_abstract:
self._obj.placeholder.id_device_supplier = (
self.id_device_supplier.data or None
)
self._obj.placeholder.info = self.info.data or None self._obj.placeholder.info = self.info.data or None
self._obj.placeholder.components = self.components.data or None
self._obj.placeholder.pallet = self.pallet.data or None self._obj.placeholder.pallet = self.pallet.data or None
self._obj.placeholder.is_abstract = False
self._obj.model = self.model.data self._obj.model = self.model.data
self._obj.manufacturer = self.manufacturer.data self._obj.manufacturer = self.manufacturer.data
self._obj.serial_number = self.serial_number.data self._obj.serial_number = self.serial_number.data
@ -601,7 +636,10 @@ class NewDeviceForm(FlaskForm):
self._obj.imei = self.imei.data self._obj.imei = self.imei.data
self._obj.meid = self.meid.data self._obj.meid = self.meid.data
if self.appearance.data and self.appearance.data != self._obj.appearance().name: if (
self.appearance.data
and self.appearance.data != self._obj.appearance().name
):
self._obj.set_appearance(self.appearance.data) self._obj.set_appearance(self.appearance.data)
if ( if (
@ -609,6 +647,7 @@ class NewDeviceForm(FlaskForm):
and self.functionality.data != self._obj.functionality().name and self.functionality.data != self._obj.functionality().name
): ):
self._obj.set_functionality(self.functionality.data) self._obj.set_functionality(self.functionality.data)
placeholder_log = PlaceholdersLog( placeholder_log = PlaceholdersLog(
type="Update", source='Web form', placeholder=self._obj.placeholder type="Update", source='Web form', placeholder=self._obj.placeholder
) )
@ -1519,6 +1558,7 @@ class UploadPlaceholderForm(FlaskForm):
self.path_snapshots = {} self.path_snapshots = {}
for i in data['Phid'].keys(): for i in data['Phid'].keys():
placeholder = None placeholder = None
data['Phid'][i] = str(data['Phid'][i])
if data['Phid'][i]: if data['Phid'][i]:
placeholder = Placeholder.query.filter_by(phid=data['Phid'][i]).first() placeholder = Placeholder.query.filter_by(phid=data['Phid'][i]).first()
@ -1627,14 +1667,14 @@ class BindingForm(FlaskForm):
self.phid.errors = [txt] self.phid.errors = [txt]
return False return False
if self.device.placeholder: if self.device.is_abstract() != 'Abstract':
txt = "This is not a device Workbench." txt = "This is not a abstract device."
self.phid.errors = [txt] self.phid.errors = [txt]
return False return False
if not self.placeholder: if not self.placeholder:
self.placeholder = Placeholder.query.filter( self.placeholder = Placeholder.query.filter(
Placeholder.phid == self.phid.data, Placeholder.owner == g.user Placeholder.phid == self.phid.data.strip(), Placeholder.owner == g.user
).first() ).first()
if not self.placeholder: if not self.placeholder:

View File

@ -37,7 +37,7 @@ from ereuse_devicehub.inventory.forms import (
) )
from ereuse_devicehub.labels.forms import PrintLabelsForm from ereuse_devicehub.labels.forms import PrintLabelsForm
from ereuse_devicehub.parser.models import PlaceholdersLog, SnapshotsLog from ereuse_devicehub.parser.models import PlaceholdersLog, SnapshotsLog
from ereuse_devicehub.resources.action.models import Trade from ereuse_devicehub.resources.action.models import ActionDevice, Trade
from ereuse_devicehub.resources.device.models import ( from ereuse_devicehub.resources.device.models import (
Computer, Computer,
DataStorage, DataStorage,
@ -191,15 +191,34 @@ class BindingView(GenericMixin):
.one() .one()
) )
if device.is_abstract() != 'Abstract':
next_url = url_for('inventory.device_details', id=dhid)
messages.error('Device "{}" not is a Abstract device!'.format(dhid))
return flask.redirect(next_url)
if device.placeholder:
device = device.placeholder.binding
if request.method == 'POST': if request.method == 'POST':
old_placeholder = device.binding old_placeholder = device.binding
old_device_placeholder = old_placeholder.device old_device_placeholder = old_placeholder.device
if old_placeholder.is_abstract: if old_placeholder.is_abstract:
for plog in PlaceholdersLog.query.filter_by( for plog in PlaceholdersLog.query.filter_by(
placeholder_id=old_placeholder.id placeholder_id=old_placeholder.id
): ):
db.session.delete(plog) db.session.delete(plog)
for ac in old_device_placeholder.actions:
ac.devices.add(placeholder.device)
ac.devices.remove(old_device_placeholder)
for act in ac.actions_device:
if act.device == old_device_placeholder:
db.session.delete(act)
db.session.delete(old_device_placeholder) db.session.delete(old_device_placeholder)
for tag in list(old_device_placeholder.tags):
tag.device = placeholder.device
device.binding = placeholder device.binding = placeholder
db.session.commit() db.session.commit()
@ -209,11 +228,16 @@ class BindingView(GenericMixin):
) )
return flask.redirect(next_url) return flask.redirect(next_url)
# import pdb; pdb.set_trace()
self.context.update( self.context.update(
{ {
'device': device.binding.device, 'device': device.binding.device,
'placeholder': placeholder, 'placeholder': placeholder,
'page_title': 'Binding confirm', 'page_title': 'Binding confirm',
'actions': list(device.binding.device.actions)
+ list(placeholder.device.actions),
'tags': list(device.binding.device.tags)
+ list(placeholder.device.tags),
} }
) )
@ -242,10 +266,8 @@ class UnBindingView(GenericMixin):
self.get_context() self.get_context()
if request.method == 'POST': if request.method == 'POST':
self.clone_device(device) new_device = self.clone_device(device)
next_url = url_for( next_url = url_for('inventory.device_details', id=new_device.devicehub_id)
'inventory.device_details', id=placeholder.device.devicehub_id
)
messages.success('Device "{}" unbind successfully!'.format(phid)) messages.success('Device "{}" unbind successfully!'.format(phid))
return flask.redirect(next_url) return flask.redirect(next_url)
@ -678,7 +700,13 @@ class ExportsView(View):
def devices_list(self): def devices_list(self):
"""Get device query and put information in csv format.""" """Get device query and put information in csv format."""
data = StringIO() data = StringIO()
cw = csv.writer(data, delimiter=';', lineterminator="\n", quotechar='"', quoting=csv.QUOTE_ALL) cw = csv.writer(
data,
delimiter=';',
lineterminator="\n",
quotechar='"',
quoting=csv.QUOTE_ALL,
)
first = True first = True
for device in self.find_devices(): for device in self.find_devices():
@ -693,7 +721,13 @@ class ExportsView(View):
def metrics(self): def metrics(self):
"""Get device query and put information in csv format.""" """Get device query and put information in csv format."""
data = StringIO() data = StringIO()
cw = csv.writer(data, delimiter=';', lineterminator="\n", quotechar='"', quoting=csv.QUOTE_ALL) cw = csv.writer(
data,
delimiter=';',
lineterminator="\n",
quotechar='"',
quoting=csv.QUOTE_ALL,
)
first = True first = True
devs_id = [] devs_id = []
# Get the allocate info # Get the allocate info
@ -757,7 +791,13 @@ class ExportsView(View):
def lots_export(self): def lots_export(self):
data = StringIO() data = StringIO()
cw = csv.writer(data, delimiter=';', lineterminator="\n", quotechar='"', quoting=csv.QUOTE_ALL) cw = csv.writer(
data,
delimiter=';',
lineterminator="\n",
quotechar='"',
quoting=csv.QUOTE_ALL,
)
cw.writerow( cw.writerow(
[ [
@ -827,7 +867,13 @@ class ExportsView(View):
def devices_lots_export(self): def devices_lots_export(self):
data = StringIO() data = StringIO()
cw = csv.writer(data, delimiter=';', lineterminator="\n", quotechar='"', quoting=csv.QUOTE_ALL) cw = csv.writer(
data,
delimiter=';',
lineterminator="\n",
quotechar='"',
quoting=csv.QUOTE_ALL,
)
head = [ head = [
'DHID', 'DHID',
'Lot Id', 'Lot Id',

View File

@ -5,6 +5,7 @@ Revises: 2b90b41a556a
Create Date: 2022-07-27 14:40:15.513820 Create Date: 2022-07-27 14:40:15.513820
""" """
import citext
import sqlalchemy as sa import sqlalchemy as sa
from alembic import context, op from alembic import context, op
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
@ -41,6 +42,11 @@ def upgrade():
sa.Column('is_abstract', sa.Boolean(), nullable=True), sa.Column('is_abstract', sa.Boolean(), nullable=True),
schema=f'{get_inv()}', schema=f'{get_inv()}',
) )
op.add_column(
'placeholder',
sa.Column('components', citext.CIText(), nullable=True),
schema=f'{get_inv()}',
)
op.add_column( op.add_column(
'placeholder', 'placeholder',
sa.Column('owner_id', postgresql.UUID(), nullable=True), sa.Column('owner_id', postgresql.UUID(), nullable=True),
@ -69,3 +75,4 @@ def downgrade():
) )
op.drop_column('placeholder', 'owner_id', schema=f'{get_inv()}') op.drop_column('placeholder', 'owner_id', schema=f'{get_inv()}')
op.drop_column('placeholder', 'is_abstract', schema=f'{get_inv()}') op.drop_column('placeholder', 'is_abstract', schema=f'{get_inv()}')
op.drop_column('placeholder', 'components', schema=f'{get_inv()}')

View File

@ -2,6 +2,7 @@ from citext import CIText
from flask import g from flask import g
from sqlalchemy import BigInteger, Column, Sequence, SmallInteger from sqlalchemy import BigInteger, Column, Sequence, SmallInteger
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import backref
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.resources.action.models import Snapshot from ereuse_devicehub.resources.action.models import Snapshot
@ -41,6 +42,8 @@ class SnapshotsLog(Thing):
def get_device(self): def get_device(self):
if self.snapshot: if self.snapshot:
if self.snapshot.device.binding:
return self.snapshot.device.binding.device.devicehub_id
return self.snapshot.device.devicehub_id return self.snapshot.device.devicehub_id
return '' return ''
@ -56,7 +59,11 @@ class PlaceholdersLog(Thing):
placeholder_id = Column(BigInteger, db.ForeignKey(Placeholder.id), nullable=True) placeholder_id = Column(BigInteger, db.ForeignKey(Placeholder.id), nullable=True)
placeholder = db.relationship( placeholder = db.relationship(
Placeholder, primaryjoin=placeholder_id == Placeholder.id Placeholder,
backref=backref(
'placeholder_logs', lazy=True, cascade="all, delete-orphan", uselist=True
),
primaryjoin=placeholder_id == Placeholder.id,
) )
owner_id = db.Column( owner_id = db.Column(
UUID(as_uuid=True), UUID(as_uuid=True),

View File

@ -666,6 +666,20 @@ class Device(Thing):
action = next(e for e in reversed(actions) if e.type == 'VisualTest') action = next(e for e in reversed(actions) if e.type == 'VisualTest')
action.functionality_range = value action.functionality_range = value
def is_abstract(self):
if self.placeholder:
if self.placeholder.is_abstract:
return 'Abstract'
if self.placeholder.binding:
return 'Twin'
return 'Real'
if self.binding:
if self.binding.is_abstract:
return 'Abstract'
return 'Twin'
return ''
def is_status(self, action): def is_status(self, action):
from ereuse_devicehub.resources.device import states from ereuse_devicehub.resources.device import states
@ -869,6 +883,7 @@ class Placeholder(Thing):
phid = Column(Unicode(), nullable=False, default=create_phid) phid = Column(Unicode(), nullable=False, default=create_phid)
pallet.comment = "used for identification where from where is this placeholders" pallet.comment = "used for identification where from where is this placeholders"
info = db.Column(CIText()) info = db.Column(CIText())
components = Column(CIText())
info.comment = "more info of placeholders" info.comment = "more info of placeholders"
is_abstract = db.Column(Boolean, default=False) is_abstract = db.Column(Boolean, default=False)
id_device_supplier = db.Column(CIText()) id_device_supplier = db.Column(CIText())
@ -883,7 +898,9 @@ class Placeholder(Thing):
) )
device = db.relationship( device = db.relationship(
Device, Device,
backref=backref('placeholder', lazy=True, cascade="all, delete-orphan", uselist=False), backref=backref(
'placeholder', lazy=True, cascade="all, delete-orphan", uselist=False
),
primaryjoin=device_id == Device.id, primaryjoin=device_id == Device.id,
) )
device_id.comment = "datas of the placeholder" device_id.comment = "datas of the placeholder"

View File

@ -139,7 +139,7 @@
<br /> <br />
{% if placeholder.device.actions or device.actions %} {% if actions %}
<h2>Actions</h2> <h2>Actions</h2>
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
@ -151,15 +151,37 @@
<tbody> <tbody>
<tr> <tr>
<td class="table-success text-right"> <td class="table-success text-right">
{% for a in placeholder.device.actions %} {% for a in actions %}
* {{ a.t }}<br /> * {{ a.t }}<br />
{% endfor %} {% endfor %}
</td> </td>
<td class="table-danger"> <td class="table-danger">
{% for a in device.actions %} </td>
* {{ a.t }}<br /> </tr>
</tbody>
</table>
{% endif %}
<br />
{% if tags %}
<h2>Tags</h2>
<table class="table table-hover">
<thead>
<tr class="text-center">
<th scope="col">Info to be Entered</th>
<th scope="col">Info to be Decoupled</th>
</tr>
</thead>
<tbody>
<tr>
<td class="table-success text-right">
{% for tag in tags %}
* {{ tag.id }}<br />
{% endfor %} {% endfor %}
</td> </td>
<td class="table-danger">
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -142,6 +142,19 @@
{% endif %} {% endif %}
</div> </div>
<div class="form-group mb-2">
<label for="label" class="form-label">{{ form.components.label }}</label>
{{ form.components(class_="form-control") }}
<small class="text-muted form-text">Description of components</small>
{% if form.components.errors %}
<p class="text-danger">
{% for error in form.components.errors %}
{{ error }}<br/>
{% endfor %}
</p>
{% endif %}
</div>
<div class="from-group has-validation mb-2" id="Serial_number"> <div class="from-group has-validation mb-2" id="Serial_number">
<label for="serialNumber" class="form-label">{{ form.serial_number.label }}</label> <label for="serialNumber" class="form-label">{{ form.serial_number.label }}</label>
{{ form.serial_number(class_="form-control") }} {{ form.serial_number(class_="form-control") }}

View File

@ -62,13 +62,13 @@
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#components">Components</button> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#components">Components</button>
</li> </li>
{% if device.binding %} {% if device.is_abstract() == 'Abstract' %}
<li class="nav-item"> <li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#binding">Binding</button> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#binding">Binding</button>
</li> </li>
{% endif %} {% endif %}
{% if device.placeholder and placeholder.binding %} {% if device.is_abstract() == 'Twin' %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('inventory.unbinding', phid=placeholder.phid) }}">Unbinding</a> <a class="nav-link" href="{{ url_for('inventory.unbinding', phid=placeholder.phid) }}">Unbinding</a>
</li> </li>
@ -79,7 +79,21 @@
<div class="tab-pane fade {% if active_binding %}profile-overview{% else %}show active{% endif %}" id="type"> <div class="tab-pane fade {% if active_binding %}profile-overview{% else %}show active{% endif %}" id="type">
<h5 class="card-title">Details</h5> <h5 class="card-title">Details</h5>
{% if device.placeholder %}(<a href="{{ url_for('inventory.device_edit', id=device.devicehub_id)}}">edit</a>){% endif %} {% if device.placeholder %}
<div class="row mb-3">
<div class="col-lg-3 col-md-4 label ">
(<a href="{{ url_for('inventory.device_edit', id=device.devicehub_id)}}">Edit Device</a>)
</div>
<div class="col-lg-9 col-md-8">{{ device.is_abstract() }}</div>
</div>
{% endif %}
{% if device.placeholder %}
<div class="row">
<div class="col-lg-3 col-md-4 label ">Phid</div>
<div class="col-lg-9 col-md-8">{{ device.placeholder.phid }}</div>
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-lg-3 col-md-4 label ">Type</div> <div class="col-lg-3 col-md-4 label ">Type</div>
@ -88,17 +102,17 @@
<div class="row"> <div class="row">
<div class="col-lg-3 col-md-4 label">Manufacturer</div> <div class="col-lg-3 col-md-4 label">Manufacturer</div>
<div class="col-lg-9 col-md-8">{{ device.manufacturer }}</div> <div class="col-lg-9 col-md-8">{{ device.manufacturer or ''}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg-3 col-md-4 label">Model</div> <div class="col-lg-3 col-md-4 label">Model</div>
<div class="col-lg-9 col-md-8">{{ device.model }}</div> <div class="col-lg-9 col-md-8">{{ device.model or ''}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg-3 col-md-4 label">Serial Number</div> <div class="col-lg-3 col-md-4 label">Serial Number</div>
<div class="col-lg-9 col-md-8">{{ device.serial_number }}</div> <div class="col-lg-9 col-md-8">{{ device.serial_number or ''}}</div>
</div> </div>
</div> </div>
@ -208,6 +222,7 @@
<div class="tab-pane fade profile-overview" id="components"> <div class="tab-pane fade profile-overview" id="components">
<h5 class="card-title">Components Details</h5> <h5 class="card-title">Components Details</h5>
{% if device.binding %}
<div class="list-group col-6"> <div class="list-group col-6">
{% for component in device.components|sort(attribute='type') %} {% for component in device.components|sort(attribute='type') %}
<div class="list-group-item"> <div class="list-group-item">
@ -227,8 +242,13 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %}
<div class="col-6">
{{ device.placeholder.components or '' }}
</div> </div>
{% if placeholder.binding %} {% endif %}
</div>
{% if device.is_abstract() %}
<div class="tab-pane fade {% if active_binding %}show active{% else %}profile-overview{% endif %}" id="binding"> <div class="tab-pane fade {% if active_binding %}show active{% else %}profile-overview{% endif %}" id="binding">
<h5 class="card-title">Binding</h5> <h5 class="card-title">Binding</h5>
<div class="list-group col-6"> <div class="list-group col-6">
@ -238,7 +258,7 @@
</p> </p>
</div> </div>
<div class="list-group col-6"> <div class="list-group col-6">
<form action="{{ url_for('inventory.device_details', id=placeholder.binding.devicehub_id) }}" method="post"> <form action="{{ url_for('inventory.device_details', id=device.devicehub_id) }}" method="post">
{{ form_binding.csrf_token }} {{ form_binding.csrf_token }}
{% for field in form_binding %} {% for field in form_binding %}
{% if field != form_binding.csrf_token %} {% if field != form_binding.csrf_token %}

View File

@ -402,7 +402,7 @@
<th scope="col">Title</th> <th scope="col">Title</th>
<th scope="col">DHID</th> <th scope="col">DHID</th>
<th scope="col">PHID</th> <th scope="col">PHID</th>
<th scope="col">Is Abstract</th> <th scope="col">Type</th>
<th scope="col">Unique Identifiers</th> <th scope="col">Unique Identifiers</th>
<th scope="col">Lifecycle Status</th> <th scope="col">Lifecycle Status</th>
<th scope="col">Allocated Status</th> <th scope="col">Allocated Status</th>
@ -448,7 +448,7 @@
{{ dev.binding and dev.binding.phid or dev.placeholder and dev.placeholder.phid or '' }} {{ dev.binding and dev.binding.phid or dev.placeholder and dev.placeholder.phid or '' }}
</td> </td>
<td> <td>
{{ dev.binding and dev.binding.is_abstract and '✓' or dev.placeholder and dev.placeholder.is_abstract and '✓' or '' }} {{ dev.is_abstract() }}
</td> </td>
<td> <td>
{% for t in dev.tags | sort(attribute="id") %} {% for t in dev.tags | sort(attribute="id") %}