Colander i Django: ColanderSchemaField

Tym razem przedstawię rozwiązanie odwrotne do poprzedniego - zastosuję walidację schematem Colandera w formularzu Django.

Budując web API bardzo często istnieje potrzeba zwalidowania i odczytania nieco bardziej skomplikowanej struktury. W przypadku gdy po stronie serwera modelem jest obiekt klasy django.db.Model, to zapewne stosowany jest formularz klasy django.forms.ModelForm. To częsta i dosyć wygodna praktyka. Jednak jeśli wejście jest bardziej złożone, a dzieje się tak często podczas enkapsulacji interfejsu za pomocą RESTful API, to istnieje tendencja do tworzenia wielu formularzy, adapterów typu complex/composite forms, albo do zmiany modelu w celu odzwierciedlenia go w interfejsie 1:1.

Żadne z tych rozwiązań nie jest wystarczająco dobre, ponieważ wiele formularzy to bardziej złożona logika walidacji i brak spójności tejże (także w kontekście komunikatów o błędach), composite/complex forms narzucają tworzenie zlepek z wielu modeli (co też nie zawsze jest konieczne), a zmiany modelu mogą być zbędne i kosztowne.

Przypuśćmy, że bardziej złożona struktura to dodatkowy poziom zagnieżdżenia danych wejściowych. Naturalnym krokiem, szczególnie przy budowaniu JSON API, jest dodanie pola typu JSONField, ktore zdeserializuje część JSON-a do słownika. Byłoby to rozwiązanie dobre gdyby nie fakt, że walidacji podlega tylko syntaktyka przekazanej wartości. Nazwy kluczy, czyli ich prawidłowość oraz ich typy i wartości, pozostają nieweryfikowalne. I nie jest to winą JSON-a, tylko jego użycia (może wynikać wprost z zastosowania prostego pola typu JSONField).

Problem można rozwiązać używając pakietu Colander, za pomocą którego definiuje się dowolne schematy. Schematy Colander mogą być komponowane w złożone struktury, ale mogą być też używane pojedynczo. Colander nie narzuca tutaj żadnej konkretnej struktury oraz nie wprowadza zbędnych zależności, zatem użycie go jest bardzo wygodne.

Rozważmy bardzo prosty schemat opisujący współrzędne geograficzne:

class LocationSchema(colander.MappingSchema):
    lat = colander.SchemaNode(colander.Float())
    lng = colander.SchemaNode(colander.Float())

Powyższy schemat przyjmie słownik z kluczami lat oraz lng, których wartości muszą być liczbami zmiennoprzecinkowymi. Krótko mówiąc przyjmie on dane postaci: {"lat": 50.0, "lng": 50.0}.

Taki schemat można podpiąć bezpośrednio pod formularz Django, tak jak każde inne pole:

class LocationForm(forms.ModelForm):
    destination = ColanderSchemaField(LocationSchema, required=True)

Oczywiście potrzebny jest adapter - dedykowane pole dla formularzy Django, za pomocą którego proces walidacji zainstancjonuje schemat LocationSchema, zweryfikuje oraz oczyści dane wejściowe.

Ciało tego adaptera jest bardzo proste:

class ColanderSchemaField(forms.Field):
    def __init__(self, schema_factory, *args, **kwargs):
        self.schema_factory = schema_factory
        super(ColanderSchemaField, self).__init__(*args, **kwargs)

    def clean(self, value):
        if value is None:
            return

        schema = self.schema_factory()
        try:
            return schema.deserialize(value)
        except colander.Invalid as ex:
            raise forms.ValidationError(ex)

Od tej chwili formularz będzie oczekiwał klucza destination o strukturze określonej klasą LocationSchema.

Jednak zapis tej informacji oczywiście nie nastąpi, ponieważ ColanderSchemaField nie jest częścią modelu. Zapis trzeba przeprowadzić jawnie:

class LocationForm(forms.ModelForm):
    # ...

    def save(self, commit=True):
        instance = super(LocationForm, self).save(commit=False)
        destination = self.cleaned_data['destination']

        instance.destination_lat = destination['lat']
        instance.destination_lng = destination['lng']

        if commit:
            instance.save()
        return instance

Jeśli model miałby zdefiniowane pole destination klasy django.contrib.postgres.fields.JSONField lub analogicznej, to aktualizacja za pomocą ColanderSchemaField powinna przebiec automatycznie bez konieczności nadpisywania metody save(). Niestety jeszcze nie testowałem tego wariantu, do czego gorąco zachęcam.

Colander i Django: ModelChoice

Budując REST API mogą przydać się walidatory danych wejściowych bardziej elastyczne niż formularze Django. Do realizacji tego celu polecam i używam Colander.

Colander jest świetnym pakietem do (de)serializacji danych. Jest to odpowiednik formularzy Django dla Pyramida, lecz zdecydowanie bardziej elastyczny oraz bez związku HTML. Formularze Django są źle zaprojektowane - zbyt ściśle związane z HTML (widgety), których przecież nie używamy budując REST API, oraz są ograniczone do płaskich struktur. Jedynie jest są dyspozycji formsety, co pozwala zwalidować i zdeserializować listę obiektów jednego typu.

Colander wolny jest od tych wad, a ponieważ nie ma zależności od Pyramid (jest pakietem samodzielnym) to można go użyć z Django bez żadnego problemu. Jednak w niektórych sytuacjach brakuje mi pola typu ModelChoice, co utrudnia fabrykowanie instancji modeli. Z tego powodu zrobiłem poniższy snippet definiujący walidator ModelOneOf oraz klasę węzła ModelChoice. Jest na tyle przydatny, że postanowiłem się z nim podzielić jak najszybciej:

import colander
import types


class ModelOneOf(object):
    def __init__(self, qs):
        self._qs = qs

    def __call__(self, node, value):
        if not self._qs.filter(pk=value).exists():
            raise colander.Invalid(node, '%r is not valid choice' % value)


class ModelChoice(colander.SchemaType):
    def __init__(self, qs, *args, **kw):
        self._qs = qs
        self._model = qs.model
        self._validate = ModelOneOf(self._qs)

        super(ModelChoice, self).__init__(*args, **kw)

    def serialize(self, node, appstruct):
        if appstruct is colander.null:
            return colander.null
        if not isinstance(appstruct, self._model):
            raise colander.Invalid(
                    node, '%r is not a %s' % (appstruct, self._model))
        return appstruct.pk

    def deserialize(self, node, cstruct):
        if cstruct is colander.null:
            return colander.null
        if not isinstance(cstruct, (types.StringType, int)):
            raise colander.Invalid(
                    node, '%r is not a string nor int' % cstruct)

        self._validate(node, cstruct)
        return self._qs.get(pk=cstruct)

Przykładowe użycie (forms.py):

import colander
from .models import MyModel


class MySchema(colander.MappingSchema):
    model = colander.SchemaNode(ModelChoice(MyModel.objects.all()))
In [1]: s = MySchema()
In [2]: s.deserialize({'model': '1'})  # gdzie '1' to wartość PK
Out[2]: {'model': <MyModel pk=1>} 

Teraz zdeserializowane dane wejściowe da się wprost przekazać do fabryk lub konstruktora modeli, np:

instance = MyModel(**s.deserialize(...))

W ten sposób można całkowicie pominąć formularze django.

Lumia 830 z Windows 10 w praktyce

W lipcu 2016 posiadacze Lumii 830 w Oragne otrzymali darmową aktualizację do Windows 10. Ponieważ Windows Phone 8.1 miał kilka wad oraz Windows 10 był szeroko promowany jako coś szczególnie wyjątkowego, postanowiłem zrobić upgrade. Przede wszystkim liczyłem na lepszą przeglądarkę WWW oraz te nieszczęsne godziny ciszy. Teraz żałuję decyzji i chyba złożę reklamację (nie ma możliwości powrotu bez flashowania obrazu z WP8.1). Telefon został zniszczony przez jego destabilizację. Mam to czego sam chciałem?

Upgrade Windows 8.1 do Windows 10

Tutaj fail. Po aktualizacji nie działa backspace w Outlooku, czyli praktycznie nie da pisać się wiadomości. Sporo programów się zawiesza czy wyskakuje. Bateria jet drenowana. W aplikacjach bałagan. Jest tylko jedno rozwiązanie - hard reset.

Hard reset

Hard reset pomaga. Outlook pozwala odpowiedzieć na mail. Klawiatura jakoś działa. Jest jakby mniej zwiech. Bateria jest drenowana. Microsoft Edge kuleje ciut mniej niż ostatni Explorer. W aplikacjach robi się porządek. Bez hard reset nie ma mowy o normalnym działaniu. Straciłem nauczony słownik i parę dupereli (nie powinno się robić backupu żeby po resecie Windows 10 był czysty).

Outlook

Kuleje i zawodzi. Synchronizacja z GMail trwa wiecznie i drenuje baterię. Outlook oficjalnie nie radzi sobie z dużymi mailboxami. Poprzedni klient dawał radę by default, a tutaj trzeba uciekać się do sztuczek. Po dodaniu konta trzeba odciąć cały net, żeby Outlook przestał synchronizować. Następnie trzeba skrócić okres synchronizacji, najlepiej do 7 dni. Po tym można włączyć net z powrotem. Jakoś działa.

Kafelki słabo się odświeżają lub wcale. Start screen pokazuje ilość wiadomości "z dupy" (coś się zcacheowało i zostało).

Edge

Jak to Internet Explorer. Wykrzacza się, silnik kiepski, często muli. Brak adblocków itp. Nie ma o czym pisać

Godziny ciszy

Tylko z Cortaną.

Cortana

Niedostępna w PL.

Bateria

Trzyma ok 1 dnia. Przy używaniu (głównie zdjęcia i filmy) - parę godzin.

Uzupełnienie 24.08.2016

Wyłączając wszystkie usługi pracujące w tle poza aplikacją Telefon i Wiadomości, bateria może wytrzymać około 3 dni przy typowym użyciu. Baterię zżerają Microsoft Edge oraz Microsoft Outlook.

Ostatnio, mimo zablokowanej pracy w tle dla Edge, wyssał on w nocy całą baterię. Ot tak, mimo zablokowania pracy w tle i zablokowania telefonu. Budzik już oczywiście nie zadziałał - zaspałem.

Dodam, że zablokowanie pracy aplikacji w tle powoduje, że nie mamy już do czynienia ze smartfonem, tylko zwykłym telefonem z modułem WIFI, GPS oraz dobrym ekranem.

Zdjęcia i filmy

Nie ma już Lumia Camera. Aplkacja Microsoft Zdjęcia zacina się i jest słabo responsywna. Zrobienie zdjęcia trwa nawet kilkanaście sekund (rekcja na touch spustu migawki to nawet 5 sekund, doliczyć trzeba czas na focus).

Czasem przy odtwarzaniu filmów głośnik pierdzi, trzeba re-regulować głośność. Coś OS przesadza, boję się o rozwalenie głośniczka.

Mapy

Here Maps wywalone. Aplikacja Microsoft Mapy jest bardziej ograniczona, nie zawsze wyszukuje poprawnie ulice. Używa plików z mapami Here, to jedyna zaleta.

Krokomierz

Najpierw działał i liczył. Przez cały tydzień. Następnie aplikacja przywitała mnie informacją pożegnalną. Instaluję Microsoft Health - zamiennik. Poprzednie dane utracone. Nie chce mi się tego używać.

Pewnego dnia odzywa się stary krokomierz po przekroczeniu 10tys kroków. Klikając w powiadomienie otrzymuję starą aplikację i historię! Euforia. Przypadkowo zamknąłem aplikację - nie da się do niej wrócić. Znowu widzę "Przenieśliśmy się..".

Aby uruchomić stary krokomierz i dostać się do archiwalnych kroków trzeba przebiec 10tys kroków!

Kompas

Nie kalibruje się. Północ mam na zachodzie. Przyzwyczaiłem się.

Komunikacja

Synchronizacja zdjęć do Google Photos nie działa. Codziennie otrzymuję powiadomienie,że nie da się wgrać zdjęć. Wgrywają się po trzy dziennie. Odinstalowuję apkę, bo wkurza.

NFC czy przesył po Bluetooth z iPhone - nie działa.

Podłączenie telefonu do OSX nie pozwala odczytać zdjęć. Nie ma nic. Tylko Linux mi czyta. Windowsa nie mam.

SMS i połączenia

Nie mam pewności czy otrzymuję każdy SMS i każdy telefon. Podczas kilku rozmów miałem problemy (przerwy, przycinania), ale to może z winy sieci. Nie ufam jednak słuchawce.

Aplikacje

  • Metro Mail, sensowy zamiennik za Outlooka - nie działa z Win10.
  • Accuweather, sensowna apka do pogody - nie działa z Win10.

Ocena ogólna

Sprzedam Lumię 830, dobrze utrzymana, w atrakcyjnej cenie. Epic fail. A było tak przyzwoicie...

Szczerze radzę unikać Windowsa 10, szczególnie na urządzeniach mobilnych. Nie dajcie się przekonywać fanbojom - to sprzedawcy, fanatycy lub kłamcy. Ten system to drwiny z użytkownika i zarazem klienta. ODRADZAM

ElasticSearch nie taki elastic

Oryginalny tekst datowany jest na 4.11.2015, a uzupełniony w lipcu 2016

ElasticSearch jest takim samym systemem wyszukiwania, jak każdy inny powstały w przeciągu ostatnich kilkudziesięciu lat. Indeks ma sztywną strukturę i nic tego nie zmieni. Jedyna różnica polega na tym, że tenże indeks jest modyfikowany w locie / w tle, co niesie za sobą sporo, czasem przykrych konsekwencji, choć na początku może wydawać się to świetnym pomysłem.

Nie-typy, czyli "mappingi"

ElasticSearch posiada tzw. "mappingi". Niektórzy mogą uważać, że są to typy indeksowanych dokumentów. Słowo "typ" z resztą pada w dokumentacji. Ale to nie są typy. Są to elementy grupujące kolumny indeksu w pewne logiczne klasy, które mogą posłużyć jako dodatkowy filtr lub zbiór definicji o analizerach i tokenizerach, oraz mogą mieć znaczenie wydajnościowe (operujemy na jakimś podzbiorze). Można je porównać do widoków znanych z RDBMS.

Należy jednak pamiętać, że kolumny (tudzież pola) w "mappingach" należą do zbioru kolumn (pól) całego indeksu. Zatem kolumny o tych samych nazwach, mimo że należą do różnych "mappingów", muszą być tego samego typu. I mimo że interfejs ElasticSearch umożliwia rozłączne definiowanie "mappingów", to próba użycia tej samej nazwy z różnymi typami spowoduje wygenerowanie błędu.

Efektem ubocznym nieuważnego stosowania mappingów są przerośnięte i niewydajne indeksy. Istnieje zalecenie niemodyfikowania "mappingów", lub ich bardzo rzadkiego modyfikowania.

Plusy? Może jakieś są, ale jestem zwolennikiem rozłącznych indeksów (z powodów wydajnościowych i architektonicznych) wraz z multiindex query.

Nie-dynamiczna struktura

Tu należy wrócić do udawanego dynamizmu ElasticSearch. Otóż dodanie pierwszego dokumentu o niby-dowolnej strukturze powoduje automatyczne rozszerzanie mappingu, indeksu, oraz zgadywanie typu kolumny (pola). To pierwszy krok do problemów z mappingami i typami, które jeśli zostaną zaniedbane mogą kończyć się żmudnym maintenance indexu a w najgorszym razie jego całkowitym usunięciem oraz zasileniem od nowa po wprowadzeniu zmian.

Podczas dynamicznego mappingu typ pola jest określany na bazie pierwszej indeksowanej wartości tego pola. Z tego wynika, że w niektórych przypadkach to los określa, kiedy nastąpi awaria - wystarczy indeksować dokumenty w nieokreślonej kolejności. A oddawanie działania systemów w ręce losu jest skrajnie nieodpowiedzialne.

Nie-RESTful API

Innym ważnym aspektem jest fakt, iż ElasticSearch nie posiada RESTful API. Autorzy mylnie lub z powodów marketingowych używają tego określenia. ElasticSearch posiada API oparte o HTTP i dokumenty JSON, ale nie jest to interfejs RESTful, przez co jest nieintuicyjny i bez dokumentacji nie da się go "konsumować". Sens interfejsu RESTful dla Elastica jest raczej znikomy, wprost przeciwnie do marketingu.

Natomiast same kwerendy JSON, mimo że potrafią być skomplikowane, są bardzo elastyczne. Wiele poziomów zagnieżdżania JSON utrudnia czytanie i pisanie kwerend, ale najważniejsza jest ich skuteczność. Zapytać można praktycznie o wszystko, także za pomocą własnych skryptów.

Skalowalność

Na koniec zwrócę uwagę na fakt, że ElasticSearch jest silnikiem skalowalnym. Oznacza to, że jego instalacja jest tożsama z wdrożeniem pewnego rodzaju klastra. Bez przemyślenia architektury tego klastra i odpowiednich kroków związanych z jego deploymentem, narażamy się na potencjalnie dużą awarię systemu, łącznie z utratą danych.

Należy także zwrócić uwagę na (write/read) consistency level. Są to parametry często występujące przy zapisie i odczycie danych. Ich nieprawidłowe użycie (w stosunku do wdrożonego klastra) może skutkować utratą danych lub ich niespójnością.

Język polski

ElasticSearch nie wspiera natywnie j. polskiego, ale istnieje do niego plugin do analizy leksykalnej - Stempel, który jest rozszerzeniem silnika Lucene.

https://github.com/elastic/elasticsearch-analysis-stempel

Podsumowanie

ElasticSearch jest ciekawym narzędziem, prostszym w konfiguracji i pierwszej instalacji niż Apache SOLR, jednakże należy używać go z głową, ostrożnie, i najlepiej unikać "dynamizmu" poprzez skrupulatną walidację danych wejściowych w części klienckiej.

Żałuję, że walidacji danych wejściowych nie robi sam silnik, tak jak to miało miejsce w przypadku Apache SOLR. Ta odpowiedzialność została przeniesiona na klienty, a w praktyce na programistów. A ponieważ nie da się wszystkiego przewidzieć, to bezrefleksyjne użycie Elastica może skończyć się nieprzewidzianą katastrofą działającego systemu.

ElasticSearch nie dysponuje narzędziami typu "DataSource". W Apache SOLR można było zdefiniować źródło danych będące zwykłym SQL-em, co umożliwiało budowanie optymalnych kwerend i zasilanie indeksu bez rozbudowywania warstwy aplikacji klienta. I choć nie zawsze jest to wygodne, to w wielu przypadkach wystarczało, oraz miało jedną istotną zaletę - zawsze działało.

ElasticSearch posiada biblioteki dla wielu języków programowania. Biblioteki dla Pythona są względnie kiepskie poza oficjalną-podstawową, która jest już dosyć rozwinięta lecz nie wolna od wad. Ponieważ odpowiedzialność za błędy mappnigów została przeniesiona na klienty, to jako programista potrzebuję biblioteki, która uniemożliwi lub utrudni mi strzelenie sobie w stopę.

Wokół ElasticSearch widzę bardzo dużo ciekawych narzędzi. Stał się też bardzo popularny. To są niewątpliwie spore zalety, bo społeczność wydaje się większa niż w przypadku Apache SOLR, i produkt ma lepsze wsparcie. Jednak wyczuwam też sporą dawkę "marketing bullshitu", o którym czuję się zobowiązany wspomnieć.

Myślę jednak, że to nie koniec moich przygód z ElasticSearch. Jak do tej pory wady nie przesłaniają zalet. Nie trzeba grzebać po plikach XML, konfigurować instalacji multicore, ani generować schematów indeksów w XML. Wszystko jest do załatwienia przez HTTP. Biblioteki, choć nieidealne, są i działają. Funkcjonalność silnika jest mocna (wszak to Lucene, tak samo jak w Apache SOLR), oraz jest sharding bez ręcznych robótek z tzw. dekompozycją obiektową.

Uzupełnienie (4.11.2015)

Nowy SOLR

Najnowsza wersja Apache SOLR również wspiera model "schemaless", tj. dynamiczne konstruowanie indeksu. Z tą różnicą, że po "zabawie" developerskiej można przełączyć się na "configured schema", aby zachować stabilność środowiska. Nie bedzie mowy o tym, że ktokolwiek (jakikolwiek klient) przypadkowo czy intencjonalnie "rozwali" indeks.

Do tego nowa wersja zapewnia skalowanie w oparciu o sprawdzony Apache Zookeeper i przedkłada spójność danych ponad inne aspekty, co zapobiega tzw. split brainowi.

Ponieważ mam jakieś tam doświadczenie z Apache SOLR (tyle że starszą wersją), wiem że język zapytań spełnia wszystkie oczekiwania, wsparcie facetingu jest więcej niż poprawne, wpływ na indeksowanie mam praktycznie nieograniczony, dostaję do tego stabilność oraz gwarancję spójności danych, a teraz jako bonus łatwiejszy sposób konstruowania indeksu, to najprawdopodobniej pozostanę zwolennikiem SOLR-a. Jest to produkt starszy i bardziej dojrzały, oraz nie sterowany sprzedażą i marketingiem. Mniej popularny? Python też jest mniej popularny. I całe szczęście.

Porównanie

Znalazłem przed chwilą porównanie SOLR z ElasticSearch: http://www.datanami.com/2015/01/22/solr-elasticsearch-question/

Polecam przeczytać.

Oprócz technicznych różnic istnieją nieco odmienne cele oraz podejście do open-source. W skrócie:

  • SOLR jest nakierowany na Full Text Search, ElasticSearch głównie na filtrowanie i agregacje
  • SOLR jest prawdziwym open-source budowanym przez community, Elastic rozwija jedna firma i jej pracownicy, którzy decydują o kierunku rozwoju
  • SOLR opiera się o Apache Zookeeper, co daje pewną gwarancję, lecz także trudność we wdrożeniu; ElasticSearch z kolei jest prostszy we wdrażeniu klastra, lecz cierpi na różne problemy "wieku dziecięcego" związanego ze spójnością danych
  • Wydajność obu jest porównywalna
  • Wsparcie obu jest porównywalne

Oraz ważne wg mnie:

  • SOLR umożliwia przełączenie się na klasyczną "sztywną" strukturę (wyłączenie pseudo-dynamizmu), co jest ważne dla utrzymania stabilności
  • SOLR dostarcza dodatkowe narzędzia pomocne przy konstruowaniu systemów wyszukiwania
  • SOLR nie przenosi odpowiedzialności za poprawność danych oraz wydajność indeksu na klienty

Uzupełnienie (12.07.2016)

ElasticSearch 2.x oferuje bogate możliwości wyszukiwania i indeksowania geospatial, z którego zaczynam korzystać w jednym z projektów. Mimo wspomnianych wcześniej wad decyduję się po raz kolejny na ElasticSearch, lecz z całą świadomością i należytą ostrożnością.

Awaria

Znajduję ponad to w sieci historię fuckupu systemu live spowodowanego przez Elastic, właśnie z powodów opisywanych wcześniej. Awaria nastąpiła niecały miesiąc po napisaniu przeze mnie pierwszej wersji tekstu. Oczywiście te dwie sprawy związku ze sobą nie mają, ale ciśnie mi się na usta klasyczne "a nie mówiłem?"

Wyłączanie zgaduj-zgaduli

Zalecam wyłączenie automatycznego mappingu, za który odpowiada parametr w elasticsearch.yml:

"index.mapper.dynamic": false

Nie można jednak zagwarantować, że jest to w 100% skuteczne w każdej wersji Elastica, bo znany jest błąd: https://github.com/elastic/elasticsearch/issues/15381

Można także wyłączyć automatyczne tworzenie indeksu:

"action.auto_create_index": false

Ostrożnej zabawy!

Jira nie nadaje sie do pracy

Tak twierdziłem wiele lat temu i tak twierdzę teraz. Jira to system, który jest nieopłacalny w utrzymaniu (nie tylko w zakupie), bardzo skomplikowany i trudny w użyciu. Niby są pluginy, niby są opcje do skonfigurowania, ale i tak istotne rzeczy są albo niemożliwe (tickety wiszące po 10 lat), albo trzeba dłubać w bazie danych.

Niespójny interfejs, denne rozwiązania UI (edit in place i zapis onblur), powolność działania (Java, żre zasoby), nieczytelne maile z notyfikacjami i podatność na uszkodzenia sprawiają, że pracuje się z Jirą źle. Ma kiepską nakładkę Agile. Może do Scruma jeszcze jakoś się nadaje, ale dla Kanbana to jest tylko słabiutka tablica. No dobra - jest jeszcze wykres CFD (wow!). Z Kanbanem ma to niewiele wspólnego.

Najgorsze jest to, że ciągle ktoś jest angażowany do naprawiania, konfigurowania, korygowania właściwie podstaw, i ciągle próbuje się coś zrobić, ustawić, opierając się na micie "wszystko w Jirze da się zrobić". No i dupa, szanowni czytelnicy, właśnie że się nie da. Jeśli kiedykolwiek ten mit od kogokolwiek usłyszycie, wspomnijcie moje słowa. Przed zakupem i wdrożeniem sprawdźcie dokładnie, czy spełni wasze oczekiwania (będą mówić, że spełni - ale to handlowcy).

Używałem przez dłuższy czas mojego własnego softu, który oczywiście ma większe braki i którego nie mam kiedy skończyć na jakimś sensownym etapie, ale pracowało mi się o wiele lepiej. Może dlatego, że soft jest tworzony pod konkretny workflow i z rozwiązaniami customowymi, których niekiedy próżno szukać nawet w takich kobyłach jak Jira.