diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..a5b00ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,514 @@ +models/__pycache__/* +static/src/js/ace/info.txt +static/src/js/ace/src-noconflict/ace.js +static/src/js/ace/src-noconflict/ext-beautify.js +static/src/js/ace/src-noconflict/ext-code_lens.js +static/src/js/ace/src-noconflict/ext-command_bar.js +static/src/js/ace/src-noconflict/ext-diff.js +static/src/js/ace/src-noconflict/ext-elastic_tabstops_lite.js +static/src/js/ace/src-noconflict/ext-emmet.js +static/src/js/ace/src-noconflict/ext-error_marker.js +static/src/js/ace/src-noconflict/ext-hardwrap.js +static/src/js/ace/src-noconflict/ext-inline_autocomplete.js +static/src/js/ace/src-noconflict/ext-keybinding_menu.js +static/src/js/ace/src-noconflict/ext-language_tools.js +static/src/js/ace/src-noconflict/ext-linking.js +static/src/js/ace/src-noconflict/ext-modelist.js +static/src/js/ace/src-noconflict/ext-options.js +static/src/js/ace/src-noconflict/ext-prompt.js +static/src/js/ace/src-noconflict/ext-rtl.js +static/src/js/ace/src-noconflict/ext-searchbox.js +static/src/js/ace/src-noconflict/ext-settings_menu.js +static/src/js/ace/src-noconflict/ext-simple_tokenizer.js +static/src/js/ace/src-noconflict/ext-spellcheck.js +static/src/js/ace/src-noconflict/ext-split.js +static/src/js/ace/src-noconflict/ext-static_highlight.js +static/src/js/ace/src-noconflict/ext-statusbar.js +static/src/js/ace/src-noconflict/ext-textarea.js +static/src/js/ace/src-noconflict/ext-themelist.js +static/src/js/ace/src-noconflict/ext-whitespace.js +static/src/js/ace/src-noconflict/info.txt +static/src/js/ace/src-noconflict/keybinding-emacs.js +static/src/js/ace/src-noconflict/keybinding-sublime.js +static/src/js/ace/src-noconflict/keybinding-vim.js +static/src/js/ace/src-noconflict/keybinding-vscode.js +static/src/js/ace/src-noconflict/mode-abap.js +static/src/js/ace/src-noconflict/mode-abc.js +static/src/js/ace/src-noconflict/mode-actionscript.js +static/src/js/ace/src-noconflict/mode-ada.js +static/src/js/ace/src-noconflict/mode-alda.js +static/src/js/ace/src-noconflict/mode-apache_conf.js +static/src/js/ace/src-noconflict/mode-apex.js +static/src/js/ace/src-noconflict/mode-applescript.js +static/src/js/ace/src-noconflict/mode-aql.js +static/src/js/ace/src-noconflict/mode-asciidoc.js +static/src/js/ace/src-noconflict/mode-asl.js +static/src/js/ace/src-noconflict/mode-assembly_arm32.js +static/src/js/ace/src-noconflict/mode-assembly_x86.js +static/src/js/ace/src-noconflict/mode-astro.js +static/src/js/ace/src-noconflict/mode-autohotkey.js +static/src/js/ace/src-noconflict/mode-basic.js +static/src/js/ace/src-noconflict/mode-batchfile.js +static/src/js/ace/src-noconflict/mode-bibtex.js +static/src/js/ace/src-noconflict/mode-c_cpp.js +static/src/js/ace/src-noconflict/mode-c9search.js +static/src/js/ace/src-noconflict/mode-cirru.js +static/src/js/ace/src-noconflict/mode-clojure.js +static/src/js/ace/src-noconflict/mode-clue.js +static/src/js/ace/src-noconflict/mode-cobol.js +static/src/js/ace/src-noconflict/mode-coffee.js +static/src/js/ace/src-noconflict/mode-coldfusion.js +static/src/js/ace/src-noconflict/mode-crystal.js +static/src/js/ace/src-noconflict/mode-csharp.js +static/src/js/ace/src-noconflict/mode-csound_document.js +static/src/js/ace/src-noconflict/mode-csound_orchestra.js +static/src/js/ace/src-noconflict/mode-csound_score.js +static/src/js/ace/src-noconflict/mode-csp.js +static/src/js/ace/src-noconflict/mode-css.js +static/src/js/ace/src-noconflict/mode-csv.js +static/src/js/ace/src-noconflict/mode-curly.js +static/src/js/ace/src-noconflict/mode-cuttlefish.js +static/src/js/ace/src-noconflict/mode-d.js +static/src/js/ace/src-noconflict/mode-dart.js +static/src/js/ace/src-noconflict/mode-diff.js +static/src/js/ace/src-noconflict/mode-django.js +static/src/js/ace/src-noconflict/mode-dockerfile.js +static/src/js/ace/src-noconflict/mode-dot.js +static/src/js/ace/src-noconflict/mode-drools.js +static/src/js/ace/src-noconflict/mode-edifact.js +static/src/js/ace/src-noconflict/mode-eiffel.js +static/src/js/ace/src-noconflict/mode-ejs.js +static/src/js/ace/src-noconflict/mode-elixir.js +static/src/js/ace/src-noconflict/mode-elm.js +static/src/js/ace/src-noconflict/mode-erlang.js +static/src/js/ace/src-noconflict/mode-flix.js +static/src/js/ace/src-noconflict/mode-forth.js +static/src/js/ace/src-noconflict/mode-fortran.js +static/src/js/ace/src-noconflict/mode-fsharp.js +static/src/js/ace/src-noconflict/mode-fsl.js +static/src/js/ace/src-noconflict/mode-ftl.js +static/src/js/ace/src-noconflict/mode-gcode.js +static/src/js/ace/src-noconflict/mode-gherkin.js +static/src/js/ace/src-noconflict/mode-gitignore.js +static/src/js/ace/src-noconflict/mode-glsl.js +static/src/js/ace/src-noconflict/mode-gobstones.js +static/src/js/ace/src-noconflict/mode-golang.js +static/src/js/ace/src-noconflict/mode-graphqlschema.js +static/src/js/ace/src-noconflict/mode-groovy.js +static/src/js/ace/src-noconflict/mode-haml.js +static/src/js/ace/src-noconflict/mode-handlebars.js +static/src/js/ace/src-noconflict/mode-haskell_cabal.js +static/src/js/ace/src-noconflict/mode-haskell.js +static/src/js/ace/src-noconflict/mode-haxe.js +static/src/js/ace/src-noconflict/mode-hjson.js +static/src/js/ace/src-noconflict/mode-html_elixir.js +static/src/js/ace/src-noconflict/mode-html_ruby.js +static/src/js/ace/src-noconflict/mode-html.js +static/src/js/ace/src-noconflict/mode-ini.js +static/src/js/ace/src-noconflict/mode-io.js +static/src/js/ace/src-noconflict/mode-ion.js +static/src/js/ace/src-noconflict/mode-jack.js +static/src/js/ace/src-noconflict/mode-jade.js +static/src/js/ace/src-noconflict/mode-java.js +static/src/js/ace/src-noconflict/mode-javascript.js +static/src/js/ace/src-noconflict/mode-jexl.js +static/src/js/ace/src-noconflict/mode-json.js +static/src/js/ace/src-noconflict/mode-json5.js +static/src/js/ace/src-noconflict/mode-jsoniq.js +static/src/js/ace/src-noconflict/mode-jsp.js +static/src/js/ace/src-noconflict/mode-jssm.js +static/src/js/ace/src-noconflict/mode-jsx.js +static/src/js/ace/src-noconflict/mode-julia.js +static/src/js/ace/src-noconflict/mode-kotlin.js +static/src/js/ace/src-noconflict/mode-latex.js +static/src/js/ace/src-noconflict/mode-latte.js +static/src/js/ace/src-noconflict/mode-less.js +static/src/js/ace/src-noconflict/mode-liquid.js +static/src/js/ace/src-noconflict/mode-lisp.js +static/src/js/ace/src-noconflict/mode-livescript.js +static/src/js/ace/src-noconflict/mode-logiql.js +static/src/js/ace/src-noconflict/mode-logtalk.js +static/src/js/ace/src-noconflict/mode-lsl.js +static/src/js/ace/src-noconflict/mode-lua.js +static/src/js/ace/src-noconflict/mode-luapage.js +static/src/js/ace/src-noconflict/mode-lucene.js +static/src/js/ace/src-noconflict/mode-makefile.js +static/src/js/ace/src-noconflict/mode-markdown.js +static/src/js/ace/src-noconflict/mode-mask.js +static/src/js/ace/src-noconflict/mode-matlab.js +static/src/js/ace/src-noconflict/mode-maze.js +static/src/js/ace/src-noconflict/mode-mediawiki.js +static/src/js/ace/src-noconflict/mode-mel.js +static/src/js/ace/src-noconflict/mode-mips.js +static/src/js/ace/src-noconflict/mode-mixal.js +static/src/js/ace/src-noconflict/mode-mushcode.js +static/src/js/ace/src-noconflict/mode-mysql.js +static/src/js/ace/src-noconflict/mode-nasal.js +static/src/js/ace/src-noconflict/mode-nginx.js +static/src/js/ace/src-noconflict/mode-nim.js +static/src/js/ace/src-noconflict/mode-nix.js +static/src/js/ace/src-noconflict/mode-nsis.js +static/src/js/ace/src-noconflict/mode-nunjucks.js +static/src/js/ace/src-noconflict/mode-objectivec.js +static/src/js/ace/src-noconflict/mode-ocaml.js +static/src/js/ace/src-noconflict/mode-odin.js +static/src/js/ace/src-noconflict/mode-partiql.js +static/src/js/ace/src-noconflict/mode-pascal.js +static/src/js/ace/src-noconflict/mode-perl.js +static/src/js/ace/src-noconflict/mode-pgsql.js +static/src/js/ace/src-noconflict/mode-php_laravel_blade.js +static/src/js/ace/src-noconflict/mode-php.js +static/src/js/ace/src-noconflict/mode-pig.js +static/src/js/ace/src-noconflict/mode-plain_text.js +static/src/js/ace/src-noconflict/mode-plsql.js +static/src/js/ace/src-noconflict/mode-powershell.js +static/src/js/ace/src-noconflict/mode-praat.js +static/src/js/ace/src-noconflict/mode-prisma.js +static/src/js/ace/src-noconflict/mode-prolog.js +static/src/js/ace/src-noconflict/mode-properties.js +static/src/js/ace/src-noconflict/mode-protobuf.js +static/src/js/ace/src-noconflict/mode-prql.js +static/src/js/ace/src-noconflict/mode-puppet.js +static/src/js/ace/src-noconflict/mode-python.js +static/src/js/ace/src-noconflict/mode-qml.js +static/src/js/ace/src-noconflict/mode-r.js +static/src/js/ace/src-noconflict/mode-raku.js +static/src/js/ace/src-noconflict/mode-razor.js +static/src/js/ace/src-noconflict/mode-rdoc.js +static/src/js/ace/src-noconflict/mode-red.js +static/src/js/ace/src-noconflict/mode-redshift.js +static/src/js/ace/src-noconflict/mode-rhtml.js +static/src/js/ace/src-noconflict/mode-robot.js +static/src/js/ace/src-noconflict/mode-rst.js +static/src/js/ace/src-noconflict/mode-ruby.js +static/src/js/ace/src-noconflict/mode-rust.js +static/src/js/ace/src-noconflict/mode-sac.js +static/src/js/ace/src-noconflict/mode-sass.js +static/src/js/ace/src-noconflict/mode-scad.js +static/src/js/ace/src-noconflict/mode-scala.js +static/src/js/ace/src-noconflict/mode-scheme.js +static/src/js/ace/src-noconflict/mode-scrypt.js +static/src/js/ace/src-noconflict/mode-scss.js +static/src/js/ace/src-noconflict/mode-sh.js +static/src/js/ace/src-noconflict/mode-sjs.js +static/src/js/ace/src-noconflict/mode-slim.js +static/src/js/ace/src-noconflict/mode-smarty.js +static/src/js/ace/src-noconflict/mode-smithy.js +static/src/js/ace/src-noconflict/mode-snippets.js +static/src/js/ace/src-noconflict/mode-soy_template.js +static/src/js/ace/src-noconflict/mode-space.js +static/src/js/ace/src-noconflict/mode-sparql.js +static/src/js/ace/src-noconflict/mode-sql.js +static/src/js/ace/src-noconflict/mode-sqlserver.js +static/src/js/ace/src-noconflict/mode-stylus.js +static/src/js/ace/src-noconflict/mode-svg.js +static/src/js/ace/src-noconflict/mode-swift.js +static/src/js/ace/src-noconflict/mode-tcl.js +static/src/js/ace/src-noconflict/mode-terraform.js +static/src/js/ace/src-noconflict/mode-tex.js +static/src/js/ace/src-noconflict/mode-text.js +static/src/js/ace/src-noconflict/mode-textile.js +static/src/js/ace/src-noconflict/mode-toml.js +static/src/js/ace/src-noconflict/mode-tsv.js +static/src/js/ace/src-noconflict/mode-tsx.js +static/src/js/ace/src-noconflict/mode-turtle.js +static/src/js/ace/src-noconflict/mode-twig.js +static/src/js/ace/src-noconflict/mode-typescript.js +static/src/js/ace/src-noconflict/mode-vala.js +static/src/js/ace/src-noconflict/mode-vbscript.js +static/src/js/ace/src-noconflict/mode-velocity.js +static/src/js/ace/src-noconflict/mode-verilog.js +static/src/js/ace/src-noconflict/mode-vhdl.js +static/src/js/ace/src-noconflict/mode-visualforce.js +static/src/js/ace/src-noconflict/mode-vue.js +static/src/js/ace/src-noconflict/mode-wollok.js +static/src/js/ace/src-noconflict/mode-xml.js +static/src/js/ace/src-noconflict/mode-xquery.js +static/src/js/ace/src-noconflict/mode-yaml.js +static/src/js/ace/src-noconflict/mode-zeek.js +static/src/js/ace/src-noconflict/mode-zig.js +static/src/js/ace/src-noconflict/theme-ambiance.js +static/src/js/ace/src-noconflict/theme-chaos.js +static/src/js/ace/src-noconflict/theme-chrome.js +static/src/js/ace/src-noconflict/theme-cloud_editor_dark.js +static/src/js/ace/src-noconflict/theme-cloud_editor.js +static/src/js/ace/src-noconflict/theme-cloud9_day.js +static/src/js/ace/src-noconflict/theme-cloud9_night_low_color.js +static/src/js/ace/src-noconflict/theme-cloud9_night.js +static/src/js/ace/src-noconflict/theme-clouds_midnight.js +static/src/js/ace/src-noconflict/theme-clouds.js +static/src/js/ace/src-noconflict/theme-cobalt.js +static/src/js/ace/src-noconflict/theme-crimson_editor.js +static/src/js/ace/src-noconflict/theme-dawn.js +static/src/js/ace/src-noconflict/theme-dracula.js +static/src/js/ace/src-noconflict/theme-dreamweaver.js +static/src/js/ace/src-noconflict/theme-eclipse.js +static/src/js/ace/src-noconflict/theme-github_dark.js +static/src/js/ace/src-noconflict/theme-github_light_default.js +static/src/js/ace/src-noconflict/theme-github.js +static/src/js/ace/src-noconflict/theme-gob.js +static/src/js/ace/src-noconflict/theme-gruvbox_dark_hard.js +static/src/js/ace/src-noconflict/theme-gruvbox_light_hard.js +static/src/js/ace/src-noconflict/theme-gruvbox.js +static/src/js/ace/src-noconflict/theme-idle_fingers.js +static/src/js/ace/src-noconflict/theme-iplastic.js +static/src/js/ace/src-noconflict/theme-katzenmilch.js +static/src/js/ace/src-noconflict/theme-kr_theme.js +static/src/js/ace/src-noconflict/theme-kuroir.js +static/src/js/ace/src-noconflict/theme-merbivore_soft.js +static/src/js/ace/src-noconflict/theme-merbivore.js +static/src/js/ace/src-noconflict/theme-mono_industrial.js +static/src/js/ace/src-noconflict/theme-monokai.js +static/src/js/ace/src-noconflict/theme-nord_dark.js +static/src/js/ace/src-noconflict/theme-one_dark.js +static/src/js/ace/src-noconflict/theme-pastel_on_dark.js +static/src/js/ace/src-noconflict/theme-solarized_dark.js +static/src/js/ace/src-noconflict/theme-solarized_light.js +static/src/js/ace/src-noconflict/theme-sqlserver.js +static/src/js/ace/src-noconflict/theme-terminal.js +static/src/js/ace/src-noconflict/theme-textmate.js +static/src/js/ace/src-noconflict/theme-tomorrow_night_blue.js +static/src/js/ace/src-noconflict/theme-tomorrow_night_bright.js +static/src/js/ace/src-noconflict/theme-tomorrow_night_eighties.js +static/src/js/ace/src-noconflict/theme-tomorrow_night.js +static/src/js/ace/src-noconflict/theme-tomorrow.js +static/src/js/ace/src-noconflict/theme-twilight.js +static/src/js/ace/src-noconflict/theme-vibrant_ink.js +static/src/js/ace/src-noconflict/theme-xcode.js +static/src/js/ace/src-noconflict/worker-base.js +static/src/js/ace/src-noconflict/worker-coffee.js +static/src/js/ace/src-noconflict/worker-css.js +static/src/js/ace/src-noconflict/worker-html.js +static/src/js/ace/src-noconflict/worker-javascript.js +static/src/js/ace/src-noconflict/worker-json.js +static/src/js/ace/src-noconflict/worker-lua.js +static/src/js/ace/src-noconflict/worker-php.js +static/src/js/ace/src-noconflict/worker-xml.js +static/src/js/ace/src-noconflict/worker-xquery.js +static/src/js/ace/src-noconflict/worker-yaml.js +static/src/js/ace/src-noconflict/snippets/abap.js +static/src/js/ace/src-noconflict/snippets/abc.js +static/src/js/ace/src-noconflict/snippets/actionscript.js +static/src/js/ace/src-noconflict/snippets/ada.js +static/src/js/ace/src-noconflict/snippets/alda.js +static/src/js/ace/src-noconflict/snippets/apache_conf.js +static/src/js/ace/src-noconflict/snippets/apex.js +static/src/js/ace/src-noconflict/snippets/applescript.js +static/src/js/ace/src-noconflict/snippets/aql.js +static/src/js/ace/src-noconflict/snippets/asciidoc.js +static/src/js/ace/src-noconflict/snippets/asl.js +static/src/js/ace/src-noconflict/snippets/assembly_arm32.js +static/src/js/ace/src-noconflict/snippets/assembly_x86.js +static/src/js/ace/src-noconflict/snippets/astro.js +static/src/js/ace/src-noconflict/snippets/autohotkey.js +static/src/js/ace/src-noconflict/snippets/basic.js +static/src/js/ace/src-noconflict/snippets/batchfile.js +static/src/js/ace/src-noconflict/snippets/bibtex.js +static/src/js/ace/src-noconflict/snippets/c_cpp.js +static/src/js/ace/src-noconflict/snippets/c9search.js +static/src/js/ace/src-noconflict/snippets/cirru.js +static/src/js/ace/src-noconflict/snippets/clojure.js +static/src/js/ace/src-noconflict/snippets/clue.js +static/src/js/ace/src-noconflict/snippets/cobol.js +static/src/js/ace/src-noconflict/snippets/coffee.js +static/src/js/ace/src-noconflict/snippets/coldfusion.js +static/src/js/ace/src-noconflict/snippets/crystal.js +static/src/js/ace/src-noconflict/snippets/csharp.js +static/src/js/ace/src-noconflict/snippets/csound_document.js +static/src/js/ace/src-noconflict/snippets/csound_orchestra.js +static/src/js/ace/src-noconflict/snippets/csound_score.js +static/src/js/ace/src-noconflict/snippets/csp.js +static/src/js/ace/src-noconflict/snippets/css.js +static/src/js/ace/src-noconflict/snippets/csv.js +static/src/js/ace/src-noconflict/snippets/curly.js +static/src/js/ace/src-noconflict/snippets/cuttlefish.js +static/src/js/ace/src-noconflict/snippets/d.js +static/src/js/ace/src-noconflict/snippets/dart.js +static/src/js/ace/src-noconflict/snippets/diff.js +static/src/js/ace/src-noconflict/snippets/django.js +static/src/js/ace/src-noconflict/snippets/dockerfile.js +static/src/js/ace/src-noconflict/snippets/dot.js +static/src/js/ace/src-noconflict/snippets/drools.js +static/src/js/ace/src-noconflict/snippets/edifact.js +static/src/js/ace/src-noconflict/snippets/eiffel.js +static/src/js/ace/src-noconflict/snippets/ejs.js +static/src/js/ace/src-noconflict/snippets/elixir.js +static/src/js/ace/src-noconflict/snippets/elm.js +static/src/js/ace/src-noconflict/snippets/erlang.js +static/src/js/ace/src-noconflict/snippets/flix.js +static/src/js/ace/src-noconflict/snippets/forth.js +static/src/js/ace/src-noconflict/snippets/fortran.js +static/src/js/ace/src-noconflict/snippets/fsharp.js +static/src/js/ace/src-noconflict/snippets/fsl.js +static/src/js/ace/src-noconflict/snippets/ftl.js +static/src/js/ace/src-noconflict/snippets/gcode.js +static/src/js/ace/src-noconflict/snippets/gherkin.js +static/src/js/ace/src-noconflict/snippets/gitignore.js +static/src/js/ace/src-noconflict/snippets/glsl.js +static/src/js/ace/src-noconflict/snippets/gobstones.js +static/src/js/ace/src-noconflict/snippets/golang.js +static/src/js/ace/src-noconflict/snippets/graphqlschema.js +static/src/js/ace/src-noconflict/snippets/groovy.js +static/src/js/ace/src-noconflict/snippets/haml.js +static/src/js/ace/src-noconflict/snippets/handlebars.js +static/src/js/ace/src-noconflict/snippets/haskell_cabal.js +static/src/js/ace/src-noconflict/snippets/haskell.js +static/src/js/ace/src-noconflict/snippets/haxe.js +static/src/js/ace/src-noconflict/snippets/hjson.js +static/src/js/ace/src-noconflict/snippets/html_elixir.js +static/src/js/ace/src-noconflict/snippets/html_ruby.js +static/src/js/ace/src-noconflict/snippets/html.js +static/src/js/ace/src-noconflict/snippets/ini.js +static/src/js/ace/src-noconflict/snippets/io.js +static/src/js/ace/src-noconflict/snippets/ion.js +static/src/js/ace/src-noconflict/snippets/jack.js +static/src/js/ace/src-noconflict/snippets/jade.js +static/src/js/ace/src-noconflict/snippets/java.js +static/src/js/ace/src-noconflict/snippets/javascript.js +static/src/js/ace/src-noconflict/snippets/jexl.js +static/src/js/ace/src-noconflict/snippets/json.js +static/src/js/ace/src-noconflict/snippets/json5.js +static/src/js/ace/src-noconflict/snippets/jsoniq.js +static/src/js/ace/src-noconflict/snippets/jsp.js +static/src/js/ace/src-noconflict/snippets/jssm.js +static/src/js/ace/src-noconflict/snippets/jsx.js +static/src/js/ace/src-noconflict/snippets/julia.js +static/src/js/ace/src-noconflict/snippets/kotlin.js +static/src/js/ace/src-noconflict/snippets/latex.js +static/src/js/ace/src-noconflict/snippets/latte.js +static/src/js/ace/src-noconflict/snippets/less.js +static/src/js/ace/src-noconflict/snippets/liquid.js +static/src/js/ace/src-noconflict/snippets/lisp.js +static/src/js/ace/src-noconflict/snippets/livescript.js +static/src/js/ace/src-noconflict/snippets/logiql.js +static/src/js/ace/src-noconflict/snippets/logtalk.js +static/src/js/ace/src-noconflict/snippets/lsl.js +static/src/js/ace/src-noconflict/snippets/lua.js +static/src/js/ace/src-noconflict/snippets/luapage.js +static/src/js/ace/src-noconflict/snippets/lucene.js +static/src/js/ace/src-noconflict/snippets/makefile.js +static/src/js/ace/src-noconflict/snippets/markdown.js +static/src/js/ace/src-noconflict/snippets/mask.js +static/src/js/ace/src-noconflict/snippets/matlab.js +static/src/js/ace/src-noconflict/snippets/maze.js +static/src/js/ace/src-noconflict/snippets/mediawiki.js +static/src/js/ace/src-noconflict/snippets/mel.js +static/src/js/ace/src-noconflict/snippets/mips.js +static/src/js/ace/src-noconflict/snippets/mixal.js +static/src/js/ace/src-noconflict/snippets/mushcode.js +static/src/js/ace/src-noconflict/snippets/mysql.js +static/src/js/ace/src-noconflict/snippets/nasal.js +static/src/js/ace/src-noconflict/snippets/nginx.js +static/src/js/ace/src-noconflict/snippets/nim.js +static/src/js/ace/src-noconflict/snippets/nix.js +static/src/js/ace/src-noconflict/snippets/nsis.js +static/src/js/ace/src-noconflict/snippets/nunjucks.js +static/src/js/ace/src-noconflict/snippets/objectivec.js +static/src/js/ace/src-noconflict/snippets/ocaml.js +static/src/js/ace/src-noconflict/snippets/odin.js +static/src/js/ace/src-noconflict/snippets/partiql.js +static/src/js/ace/src-noconflict/snippets/pascal.js +static/src/js/ace/src-noconflict/snippets/perl.js +static/src/js/ace/src-noconflict/snippets/pgsql.js +static/src/js/ace/src-noconflict/snippets/php_laravel_blade.js +static/src/js/ace/src-noconflict/snippets/php.js +static/src/js/ace/src-noconflict/snippets/pig.js +static/src/js/ace/src-noconflict/snippets/plain_text.js +static/src/js/ace/src-noconflict/snippets/plsql.js +static/src/js/ace/src-noconflict/snippets/powershell.js +static/src/js/ace/src-noconflict/snippets/praat.js +static/src/js/ace/src-noconflict/snippets/prisma.js +static/src/js/ace/src-noconflict/snippets/prolog.js +static/src/js/ace/src-noconflict/snippets/properties.js +static/src/js/ace/src-noconflict/snippets/protobuf.js +static/src/js/ace/src-noconflict/snippets/prql.js +static/src/js/ace/src-noconflict/snippets/puppet.js +static/src/js/ace/src-noconflict/snippets/python.js +static/src/js/ace/src-noconflict/snippets/qml.js +static/src/js/ace/src-noconflict/snippets/r.js +static/src/js/ace/src-noconflict/snippets/raku.js +static/src/js/ace/src-noconflict/snippets/razor.js +static/src/js/ace/src-noconflict/snippets/rdoc.js +static/src/js/ace/src-noconflict/snippets/red.js +static/src/js/ace/src-noconflict/snippets/redshift.js +static/src/js/ace/src-noconflict/snippets/rhtml.js +static/src/js/ace/src-noconflict/snippets/robot.js +static/src/js/ace/src-noconflict/snippets/rst.js +static/src/js/ace/src-noconflict/snippets/ruby.js +static/src/js/ace/src-noconflict/snippets/rust.js +static/src/js/ace/src-noconflict/snippets/sac.js +static/src/js/ace/src-noconflict/snippets/sass.js +static/src/js/ace/src-noconflict/snippets/scad.js +static/src/js/ace/src-noconflict/snippets/scala.js +static/src/js/ace/src-noconflict/snippets/scheme.js +static/src/js/ace/src-noconflict/snippets/scrypt.js +static/src/js/ace/src-noconflict/snippets/scss.js +static/src/js/ace/src-noconflict/snippets/sh.js +static/src/js/ace/src-noconflict/snippets/sjs.js +static/src/js/ace/src-noconflict/snippets/slim.js +static/src/js/ace/src-noconflict/snippets/smarty.js +static/src/js/ace/src-noconflict/snippets/smithy.js +static/src/js/ace/src-noconflict/snippets/snippets.js +static/src/js/ace/src-noconflict/snippets/soy_template.js +static/src/js/ace/src-noconflict/snippets/space.js +static/src/js/ace/src-noconflict/snippets/sparql.js +static/src/js/ace/src-noconflict/snippets/sql.js +static/src/js/ace/src-noconflict/snippets/sqlserver.js +static/src/js/ace/src-noconflict/snippets/stylus.js +static/src/js/ace/src-noconflict/snippets/svg.js +static/src/js/ace/src-noconflict/snippets/swift.js +static/src/js/ace/src-noconflict/snippets/tcl.js +static/src/js/ace/src-noconflict/snippets/terraform.js +static/src/js/ace/src-noconflict/snippets/tex.js +static/src/js/ace/src-noconflict/snippets/text.js +static/src/js/ace/src-noconflict/snippets/textile.js +static/src/js/ace/src-noconflict/snippets/toml.js +static/src/js/ace/src-noconflict/snippets/tsv.js +static/src/js/ace/src-noconflict/snippets/tsx.js +static/src/js/ace/src-noconflict/snippets/turtle.js +static/src/js/ace/src-noconflict/snippets/twig.js +static/src/js/ace/src-noconflict/snippets/typescript.js +static/src/js/ace/src-noconflict/snippets/vala.js +static/src/js/ace/src-noconflict/snippets/vbscript.js +static/src/js/ace/src-noconflict/snippets/velocity.js +static/src/js/ace/src-noconflict/snippets/verilog.js +static/src/js/ace/src-noconflict/snippets/vhdl.js +static/src/js/ace/src-noconflict/snippets/visualforce.js +static/src/js/ace/src-noconflict/snippets/vue.js +static/src/js/ace/src-noconflict/snippets/wollok.js +static/src/js/ace/src-noconflict/snippets/xml.js +static/src/js/ace/src-noconflict/snippets/xquery.js +static/src/js/ace/src-noconflict/snippets/yaml.js +static/src/js/ace/src-noconflict/snippets/zeek.js +static/src/js/ace/src-noconflict/snippets/zig.js +models/__pycache__/__init__.cpython-311.pyc +models/__pycache__/company.cpython-311.pyc +models/__pycache__/dss_ads.cpython-311.pyc +models/__pycache__/dss_advertisefields.cpython-311.pyc +models/__pycache__/dss_binaries.cpython-311.pyc +models/__pycache__/dss_contract_uservar.cpython-311.pyc +models/__pycache__/dss_contract.cpython-311.pyc +models/__pycache__/dss_crm_extended.cpython-311.pyc +models/__pycache__/dss_display_templates.cpython-311.pyc +models/__pycache__/dss_eventdays.cpython-311.pyc +models/__pycache__/dss_kileads_requests.cpython-311.pyc +models/__pycache__/dss_kileads.cpython-311.pyc +models/__pycache__/dss_linkmail.cpython-311.pyc +models/__pycache__/dss_m2mmail.cpython-311.pyc +models/__pycache__/dss_maintains.cpython-311.pyc +models/__pycache__/dss_marker.cpython-311.pyc +models/__pycache__/dss_onlinestate.cpython-311.pyc +models/__pycache__/dss_projects.cpython-311.pyc +models/__pycache__/dss_res_users.cpython-311.pyc +models/__pycache__/dss_settings_variables.cpython-311.pyc +models/__pycache__/dss_settings.cpython-311.pyc +models/__pycache__/dss_systemtypen.cpython-311.pyc +models/__pycache__/dss_trigger.cpython-311.pyc +models/__pycache__/dss_web_contracts_all.cpython-311.pyc +models/__pycache__/dss_web_contracts_all2.cpython-311.pyc +models/__pycache__/dss_web_contracts_entry.cpython-311.pyc +models/__pycache__/dss_web_contracts_stream.cpython-311.pyc +models/__pycache__/dss_web_contracts.cpython-311.pyc +models/__pycache__/dss.cpython-311.pyc +models/__pycache__/dsslogger.cpython-311.pyc +models/__pycache__/utils.cpython-311.pyc diff --git a/__manifest__.py b/__manifest__.py index 3e0a6e0..bb896ea 100755 --- a/__manifest__.py +++ b/__manifest__.py @@ -21,6 +21,7 @@ 'views/dss_display_templates.xml', 'views/dss_advertisementfields_templates.xml', 'views/dss_advertisementfields.xml', + 'views/dss_advertisementfields_create_from_template_wizard.xml', 'views/dss_projects.xml', 'views/dss_import.xml', 'views/dss_ads.xml', @@ -50,6 +51,11 @@ 'views/dss_binaries.xml', 'views/mainsystem_view.xml', 'views/dss_importinvoicelist.xml', + 'views/dss_company_type_views.xml', + 'views/dss_csvs.xml', + 'views/dss_phone_center.xml', + 'views/dss_phonecenter_query_views.xml', + 'views/menu.xml', 'views/company_view.xml', 'views/google_map.xml', @@ -62,6 +68,7 @@ 'views/dss_web_contracts_all_templates.xml', 'views/dss_contract_uservars.xml', 'views/dss_portal_add.xml', + 'views/adportal_dashboard.xml', #'views/dss_screendesign.xml', ], 'demo': [], @@ -72,6 +79,9 @@ 'assets': { 'web.assets_backend': [ 'DigitalSignage/static/src/img/*', + 'DigitalSignage/static/src/xml/navbar_ai_button.xml', + 'DigitalSignage/static/src/js/navbar_ai_button.js', + 'DigitalSignage/static/src/scss/navbar_ai_button.scss', 'DigitalSignage/static/src/xml/screenDesignerView.xml', 'DigitalSignage/static/src/xml/dss_screenview_controller.xml', 'DigitalSignage/static/src/xml/dss_screenview_renderer.xml', @@ -100,9 +110,12 @@ 'DigitalSignage/static/src/js/kanban_button.js', 'DigitalSignage/static/src/js/dss_crm_extended_ki_leads.js', 'DigitalSignage/static/src/js/kilead_hook.js', + 'DigitalSignage/static/src/js/phone_center_action.js', 'DigitalSignage/static/src/xml/form_button.xml', 'DigitalSignage/static/src/xml/kanban_button.xml', 'DigitalSignage/static/src/xml/form_label.xml', + 'DigitalSignage/static/src/xml/phone_center_action.xml', + 'DigitalSignage/static/src/scss/phone_center_action.scss', 'DigitalSignage/static/src/js/test.php', ], 'web.assets_common': [ @@ -117,6 +130,7 @@ ], 'web.assets_qweb': [ 'DigitalSignage/static/src/xml/kanban_button.xml', + 'DigitalSignage/static/src/xml/phone_center_action.xml', ], }, 'license': 'LGPL-3', diff --git a/controllers/__init__.py b/controllers/__init__.py index 57977ef..0b12e92 100755 --- a/controllers/__init__.py +++ b/controllers/__init__.py @@ -6,3 +6,5 @@ from . import dss_screendesigner_controller from . import dss_screenview_controller from . import dss_nextcloudwidget from . import dss_form_input_controller +from . import ai_agent_controller +from . import adportal diff --git a/controllers/__pycache__/__init__.cpython-311.pyc b/controllers/__pycache__/__init__.cpython-311.pyc old mode 100644 new mode 100755 index 43c1882..d3fe091 Binary files a/controllers/__pycache__/__init__.cpython-311.pyc and b/controllers/__pycache__/__init__.cpython-311.pyc differ diff --git a/controllers/__pycache__/adportal.cpython-311.pyc b/controllers/__pycache__/adportal.cpython-311.pyc new file mode 100755 index 0000000..9141cc3 Binary files /dev/null and b/controllers/__pycache__/adportal.cpython-311.pyc differ diff --git a/controllers/__pycache__/ai_agent_controller.cpython-311.pyc b/controllers/__pycache__/ai_agent_controller.cpython-311.pyc new file mode 100755 index 0000000..a152c87 Binary files /dev/null and b/controllers/__pycache__/ai_agent_controller.cpython-311.pyc differ diff --git a/controllers/__pycache__/dss_form_input_controller.cpython-311.pyc b/controllers/__pycache__/dss_form_input_controller.cpython-311.pyc old mode 100644 new mode 100755 index 447b1f4..06e838c Binary files a/controllers/__pycache__/dss_form_input_controller.cpython-311.pyc and b/controllers/__pycache__/dss_form_input_controller.cpython-311.pyc differ diff --git a/controllers/__pycache__/dss_screendesigner_controller.cpython-311.pyc b/controllers/__pycache__/dss_screendesigner_controller.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/controllers/adportal.py b/controllers/adportal.py new file mode 100755 index 0000000..ebd95ff --- /dev/null +++ b/controllers/adportal.py @@ -0,0 +1,1161 @@ +# -*- coding: utf-8 -*- + +import json +import mimetypes +import posixpath +import tempfile +import base64 +import logging +from dateutil.relativedelta import relativedelta +from urllib.error import URLError +from urllib.parse import quote, unquote, urlencode +from urllib.request import Request, urlopen + +from odoo import http +from odoo.http import request +from webdav4.fsspec import WebdavFileSystem +from werkzeug.exceptions import NotFound + + +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.DEBUG) +_logger.setloging(True) + + + +class AdPortalController(http.Controller): + + MEDIA_ROOT_SUFFIX = '02_Fertig' + + def _json_response(self, payload, status=200): + return request.make_response( + json.dumps(payload), + headers=[('Content-Type', 'application/json')], + status=status, + ) + + def _get_adportal_settings(self): + return request.env['dss.settings'].sudo().search([], limit=1) + + def _get_visible_graphic_templates(self): + return request.env['dss.mediatypes'].sudo().search([ + ('adportal_visible', '=', True), + ], order='medianame asc, id asc') + + def _resolve_graphic_resolution(self, template): + default_resolution = { + 'width': 1024, + 'height': 1024, + 'label': '1024 x 1024', + } + if not template: + return default_resolution + + width = int(template.maxsize_w or 0) + height = int(template.maxsize_h or 0) + if width <= 0 or height <= 0: + return default_resolution + + return { + 'width': width, + 'height': height, + 'label': '%s x %s' % (width, height), + } + + def _get_openai_image_size(self, model, width, height): + width = int(width or 0) + height = int(height or 0) + if width <= 0 or height <= 0: + return '1024x1024' + + portrait = height > width + square = width == height + model_name = (model or '').strip() + + if square: + return '1024x1024' + if portrait: + if model_name == 'dall-e-3': + return '1024x1792' + return '1024x1536' + if model_name == 'dall-e-3': + return '1792x1024' + return '1536x1024' + + def _build_graphic_prompt( + self, + settings, + company_name, + company_address='', + instruction='', + resolution_label='', + mediatype_name='', + mediatype_prompt='', + company_context='', + ): + base_prompt = (settings.adportal_graphics_base_prompt or '').strip() if settings else '' + parts = [] + if base_prompt: + parts.append(base_prompt) + if mediatype_name: + parts.append('Medientyp: %s' % mediatype_name) + if mediatype_prompt: + parts.append('Medientyp KI Prompt: %s' % mediatype_prompt) + parts.append('Firma: %s' % company_name) + if company_address: + parts.append('Adresse: %s' % company_address) + if company_context: + parts.append(company_context) + if resolution_label: + parts.append('Aufloesung: %s Pixel' % resolution_label) + if instruction: + parts.append('Aenderung: %s' % instruction) + return '\n\n'.join(parts) + + def _build_graphic_prompt_with_chatgpt( + self, + settings, + company_name, + company_address='', + instruction='', + resolution_label='', + mediatype_name='', + mediatype_prompt='', + company_context='', + ): + prompt = self._build_graphic_prompt( + settings, + company_name, + company_address=company_address, + instruction=instruction, + resolution_label=resolution_label, + mediatype_name=mediatype_name, + mediatype_prompt=mediatype_prompt, + company_context=company_context, + ) + return prompt, '' + + def _get_company_context_from_google(self, settings, company_name): + if not settings: + return '', '' + + api_key = (settings.adportal_google_api_key or '').strip() + cse_id = (settings.adportal_google_cse_id or '').strip() + if not api_key or not cse_id: + return '', '' + + query_url = 'https://www.googleapis.com/customsearch/v1?%s' % urlencode({ + 'key': api_key, + 'cx': cse_id, + 'q': company_name, + 'num': 3, + }) + req = Request(query_url, headers={'Accept': 'application/json'}) + try: + with urlopen(req, timeout=10) as response: + payload = json.loads(response.read().decode('utf-8')) + except Exception: + return '', 'Google Firmeninformationen konnten nicht geladen werden.' + + items = payload.get('items') or [] + if not items: + return '', '' + + snippets = [] + for item in items[:3]: + title = (item.get('title') or '').strip() + snippet = (item.get('snippet') or '').strip().replace('\n', ' ') + link = (item.get('link') or '').strip() + info_parts = [part for part in (title, snippet, link) if part] + if info_parts: + snippets.append(' - '.join(info_parts)) + + if not snippets: + return '', '' + + return 'Google Firmenkontext:\n%s' % '\n'.join(snippets), '' + + def _create_graphic_with_chatgpt(self, settings, prompt, current_image='', resolution_width=0, resolution_height=0): + if not settings: + return None, 'Keine AD Portal Settings gefunden.' + + try: + from openai import OpenAI + except Exception: + return None, 'openai package not available' + + settings_icp = request.env['ir.config_parameter'].sudo() + api_key = (settings.adportal_graphics_api_key or '').strip() or (settings_icp.get_param('digitalsignage_ai.api_key') or '').strip() + if not api_key: + return None, 'ChatGPT API Key fehlt.' + + base_url = (settings_icp.get_param('digitalsignage_ai.base_url') or 'https://api.openai.com/v1').rstrip('/') + model = (settings.adportal_graphics_chatgpt_model or 'gpt-image-1').strip() + image_size = self._get_openai_image_size(model, resolution_width, resolution_height) + + try: + client = OpenAI(api_key=api_key, base_url=base_url) + if current_image: + image_bytes = None + if current_image.startswith('data:image/') and ';base64,' in current_image: + image_bytes = base64.b64decode(current_image.split(';base64,', 1)[1]) + elif current_image.startswith('data:') and ';base64,' in current_image: + image_bytes = base64.b64decode(current_image.split(';base64,', 1)[1]) + elif current_image.startswith('http://') or current_image.startswith('https://'): + with urlopen(Request(current_image, headers={'Accept': 'application/octet-stream'}), timeout=30) as response: + image_bytes = response.read() + + if image_bytes: + with tempfile.NamedTemporaryFile(suffix='.png') as image_file: + image_file.write(image_bytes) + image_file.flush() + with open(image_file.name, 'rb') as stream: + response = client.images.edits( + model=model, + image=stream, + prompt=prompt, + size=image_size, + ) + else: + response = client.images.generate( + model=model, + prompt=prompt, + size=image_size, + ) + else: + response = client.images.generate( + model=model, + prompt=prompt, + size=image_size, + ) + + if response and getattr(response, 'data', None): + first_item = response.data[0] + image_url = getattr(first_item, 'url', '') or '' + image_b64 = getattr(first_item, 'b64_json', '') or '' + if image_url: + return {'image_url': image_url, 'image_data': ''}, '' + if image_b64: + return {'image_url': '', 'image_data': 'data:image/png;base64,%s' % image_b64}, '' + return None, 'OpenAI hat kein Bild geliefert.' + except Exception as exc: + return None, str(exc) + + def _validate_company_with_google(self, settings, company_name): + if not settings or not settings.adportal_google_company_check: + return True, '', [{ + 'id': 'manual', + 'name': company_name, + 'street': '', + 'city': '', + 'postal_code': '', + 'region': '', + 'country': '', + 'full_address': company_name, + }] + + api_key = (settings.adportal_google_api_key or '').strip() + cse_id = (settings.adportal_google_cse_id or '').strip() + if not api_key or not cse_id: + return False, 'Google Pruefung aktiv, aber API Key oder CSE ID fehlt in den Settings.', [] + + _logger.info('ADPORTAL : Validating company "%s" with Google Search API', company_name) + + + import requests + + url = "https://local-business-data.p.rapidapi.com/search" + + querystring = {"query":"Firma wie "+company_name,"limit":"20","zoom":"13","language":"en","region":"de","extract_emails_and_contacts":"false"} + + headers = { + "x-rapidapi-key": "6eb64b4d3bmshe5bc9fc666cce68p13a32djsna3f717099ebf", + "x-rapidapi-host": "local-business-data.p.rapidapi.com", + "Content-Type": "application/json" + } + + response = requests.get(url, headers=headers, params=querystring) + data = response.json() + + _logger.info('ADPORTAL : Google Search API response: %s', str(data)) + + status_value = str(data.get('status') or '').strip().upper() + if status_value != 'OK': + return False, 'Google Firmenpruefung nicht OK (status: %s).' % (status_value or 'leer'), [] + + records = data.get('data') or [] + if not isinstance(records, list) or not records: + return False, 'Keine passenden Firmenadressen gefunden.', [] + + addresses = [] + for idx, row in enumerate(records): + if not isinstance(row, dict): + continue + + name = (row.get('name') or row.get('business_name') or company_name or '').strip() + street = (row.get('street') or row.get('address') or '').strip() + city = (row.get('city') or '').strip() + postal_code = (row.get('zip') or row.get('postal_code') or row.get('postcode') or '').strip() + region = (row.get('state') or row.get('region') or '').strip() + country = (row.get('country') or '').strip() + + parts = [part for part in (street, postal_code, city, region, country) if part] + full_address = ', '.join(parts) + if not full_address: + full_address = (row.get('formatted_address') or '').strip() + + addresses.append({ + 'id': str(row.get('place_id') or row.get('cid') or (idx + 1)), + 'name': name or company_name, + 'street': street, + 'city': city, + 'postal_code': postal_code, + 'region': region, + 'country': country, + 'full_address': full_address, + }) + + if not addresses: + return False, 'Keine auswertbaren Firmenadressen gefunden.', [] + return True, '', addresses + + + + def _validate_company_address(self, settings, company_name, company_address): + address_value = (company_address or '').strip() + if not address_value: + return False, 'Bitte eine Adresse eingeben.' + if len(address_value) < 6: + return False, 'Adresse ist zu kurz. Bitte vollstaendige Adresse eingeben.' + + if not settings or not settings.adportal_google_company_check: + return True, '' + + api_key = (settings.adportal_google_api_key or '').strip() + cse_id = (settings.adportal_google_cse_id or '').strip() + if not api_key or not cse_id: + return False, 'Google Pruefung aktiv, aber API Key oder CSE ID fehlt in den Settings.' + + query_text = '%s %s' % ((company_name or '').strip(), address_value) + query_url = 'https://www.googleapis.com/customsearch/v1?%s' % urlencode({ + 'key': api_key, + 'cx': cse_id, + 'q': query_text, + 'num': 1, + }) + req = Request(query_url, headers={'Accept': 'application/json'}) + try: + with urlopen(req, timeout=10) as response: + payload = json.loads(response.read().decode('utf-8')) + except Exception: + return False, 'Adresspruefung konnte nicht ausgefuehrt werden.' + + if payload.get('items'): + return True, '' + return False, 'Adresse konnte nicht bestaetigt werden. Bitte Adresse pruefen.' + + def _call_graphics_engine(self, settings, payload): + if not settings: + return None, 'Keine AD Portal Settings gefunden.' + + if settings.adportal_graphics_use_chatgpt: + image_result, error = self._create_graphic_with_chatgpt( + settings, + payload.get('prompt') or '', + current_image=payload.get('current_image') or '', + resolution_width=payload.get('resolution_width') or 0, + resolution_height=payload.get('resolution_height') or 0, + ) + if error: + return None, error + return image_result, '' + + api_url = (settings.adportal_graphics_api_url or '').strip() + api_key = (settings.adportal_graphics_api_key or '').strip() + timeout = int(settings.adportal_graphics_api_timeout or 90) + + if not api_url: + return None, 'Grafik API URL fehlt in den AD Portal Settings.' + + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + if api_key: + headers['Authorization'] = 'Bearer %s' % api_key + headers['X-API-Key'] = api_key + + req = Request(api_url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST') + try: + with urlopen(req, timeout=max(5, timeout)) as response: + body = response.read().decode('utf-8') + return json.loads(body or '{}'), '' + except URLError as exc: + return None, 'Grafik API nicht erreichbar: %s' % str(exc) + except Exception as exc: + return None, 'Grafik API Fehler: %s' % str(exc) + + def _extract_image_from_ai_result(self, data): + if not data: + return '', '' + + if isinstance(data, dict): + image_data = data.get('image_data') + if isinstance(image_data, str) and image_data.strip(): + return '', image_data.strip() + + direct_url_keys = ('image_url', 'url', 'output_image_url') + for key in direct_url_keys: + value = data.get(key) if isinstance(data, dict) else None + if isinstance(value, str) and value.strip(): + return value.strip(), '' + + if isinstance(data, dict): + image_block = data.get('image') + if isinstance(image_block, dict): + image_url = image_block.get('url') + if isinstance(image_url, str) and image_url.strip(): + return image_url.strip(), '' + b64 = image_block.get('b64_json') or image_block.get('base64') + if isinstance(b64, str) and b64.strip(): + return '', 'data:image/png;base64,%s' % b64.strip() + + images = data.get('images') + if isinstance(images, list) and images: + first = images[0] + if isinstance(first, str) and first.strip(): + return first.strip(), '' + if isinstance(first, dict): + image_url = first.get('url') + if isinstance(image_url, str) and image_url.strip(): + return image_url.strip(), '' + b64 = first.get('b64_json') or first.get('base64') + if isinstance(b64, str) and b64.strip(): + return '', 'data:image/png;base64,%s' % b64.strip() + + b64_top = data.get('image_base64') or data.get('b64_json') + if isinstance(b64_top, str) and b64_top.strip(): + return '', 'data:image/png;base64,%s' % b64_top.strip() + + return '', '' + + def _user_has_portal_right(self, user, field_name): + if field_name not in (getattr(user, '_fields', {}) or {}): + return True + return bool(getattr(user, field_name, True)) + + def _ensure_portal_right(self, user, field_name): + if not self._user_has_portal_right(user, field_name): + raise NotFound() + + def _get_portal_right_label(self, user, field_name): + labels = { + 'portal_right_contract_overview': 'Vertragsanzeige', + 'portal_right_contract_detail': 'Vertragsdetails', + 'portal_right_media_view': 'Medienanzeige', + 'portal_right_ranking_view': 'Rankinganzeige', + 'portal_right_graphic_examples': 'Grafikbeispiele anzeigen/erstellen', + } + return labels.get(field_name, field_name) + + def _normalize_cloud_path(self, path): + path_value = (path or '').strip().replace('\\', '/') + if not path_value: + return '' + normalized = posixpath.normpath('/' + path_value).lstrip('/') + return '' if normalized == '.' else normalized + + def _join_cloud_paths(self, left, right): + if not left: + return self._normalize_cloud_path(right) + if not right: + return self._normalize_cloud_path(left) + return self._normalize_cloud_path('%s/%s' % (left.rstrip('/'), right.lstrip('/'))) + + def _ensure_contract_portal_access(self, contract, contract_model, user): + if not contract: + raise NotFound() + + contract_fields = getattr(contract_model, '_fields', {}) or {} + is_creator = True + if 'contract_creator' in contract_fields: + if contract.contract_creator: + is_creator = contract.contract_creator.id == user.id + + is_writer_with_project_partner = False + partner_id = user.partner_id.id if getattr(user, 'partner_id', False) else False + if partner_id and 'contract_writer' in contract_fields and contract.contract_writer and contract.contract_writer.id == partner_id: + project = contract.project + project_fields = getattr(project, '_fields', {}) if project else {} + if project and 'vertriebspartner' in (project_fields or {}): + is_writer_with_project_partner = partner_id in project.vertriebspartner.ids + + if not (is_creator or is_writer_with_project_partner): + raise NotFound() + + def _get_user_contract_visibility_domain(self, user, require_project=False, project_id=False): + base_domain = [] + if require_project: + base_domain.append(('project', '!=', False)) + if project_id: + base_domain.append(('project', '=', project_id)) + + creator_domain = [('contract_creator', '=', user.id)] + partner_id = user.partner_id.id if getattr(user, 'partner_id', False) else False + if not partner_id: + return base_domain + creator_domain + + # Additional visibility: user is contract_writer and is in project.vertriebspartner. + writer_and_partner_domain = ['&', + ('contract_writer', '=', partner_id), + ('project.vertriebspartner', 'in', [partner_id]), + ] + return base_domain + ['|'] + creator_domain + writer_and_partner_domain + + def _compute_contract_end_without_extension(self, contract): + _logger.warning('ADPORTAL: compute end (no extension) CALLED for contract id=%s', contract.id if contract else 'n/a') + if not contract: + return False + + start_date = contract.start_date or contract.contract_date + if not start_date: + return False + + if contract.contract_iscanceled and contract.ads_last_todo_state_until: + _logger.warning('ADPORTAL: contract id=%s is canceled, using ads_last_todo_state_until=%s', contract.id, contract.ads_last_todo_state_until) + return contract.ads_last_todo_state_until + + runtime_months = int(contract.runtime_m or 0) + int(contract.runtime_bonus_m or 0) + if contract.contract_auto_extend: + try: + extension_months = int((contract.contract_auto_extend_time or '0').strip() or 0) + except Exception: + extension_months = 0 + runtime_months += extension_months + _logger.warning('ADPORTAL: contract id=%s has auto extension=%s months', contract.id, extension_months) + + _logger.warning('ADPORTAL: compute end inputs contract id=%s start_date=%s contract_date=%s runtime_m=%s runtime_bonus_m=%s total_months=%s', contract.id, contract.start_date, contract.contract_date, contract.runtime_m, contract.runtime_bonus_m, runtime_months) + return start_date + relativedelta(months=runtime_months) + + def _get_webdav_connection_data(self): + settings = request.env['dss.settings'].sudo().search([], limit=1) + cloud_base = (settings.def_cloud_url_base or '').strip() if settings else '' + if not cloud_base: + return None, None, None + + dav_base = cloud_base + marker = 'index.php/apps/files/?dir=' + if marker in dav_base: + dav_base = '%sremote.php/dav/files/OdooDav/' % dav_base.split(marker)[0] + elif '/remote.php/dav/files/' not in dav_base: + dav_base = dav_base.rstrip('/') + '/remote.php/dav/files/OdooDav/' + + config = request.env['ir.config_parameter'].sudo() + webdav_user = config.get_param('digitalsignage.webdav_user') or 'odooClient@logumedia.de' + webdav_password = config.get_param('digitalsignage.webdav_password') or 'lm2020#OdooDav' + return dav_base, webdav_user, webdav_password + + def _get_contract_cloud_root(self, contract): + cloud_root = contract.cloudlink or '' + if not cloud_root: + return '' + + settings = request.env['dss.settings'].sudo().search([], limit=1) + if settings: + try: + cloud_root = settings._get_path_converted(cloud_root, contract) or cloud_root + except Exception: + cloud_root = contract.cloudlink or '' + + normalized = self._normalize_cloud_path(cloud_root) + if not normalized: + return '' + if normalized.split('/')[-1] == self.MEDIA_ROOT_SUFFIX: + return normalized + return self._join_cloud_paths(normalized, self.MEDIA_ROOT_SUFFIX) + + def _build_media_breadcrumbs(self, contract_uuid, current_rel_path): + breadcrumbs = [ + { + 'name': self.MEDIA_ROOT_SUFFIX, + 'path': '', + 'href': '/adportal/contracts/%s' % contract_uuid, + 'is_current': current_rel_path == '', + } + ] + if not current_rel_path: + return breadcrumbs + + segments = [segment for segment in current_rel_path.split('/') if segment] + running_path = '' + for index, segment in enumerate(segments): + running_path = self._join_cloud_paths(running_path, segment) + breadcrumbs.append({ + 'name': segment, + 'path': running_path, + 'href': '/adportal/contracts/%s?media_path=%s' % (contract_uuid, quote(running_path)), + 'is_current': index == len(segments) - 1, + }) + return breadcrumbs + + def _get_contract_media_entries(self, contract, relative_path=''): + dav_base, webdav_user, webdav_password = self._get_webdav_connection_data() + cloud_root = self._get_contract_cloud_root(contract) + + if not dav_base or not cloud_root: + return { + 'enabled': False, + 'entries': [], + 'current_path': '', + 'parent_path': '', + 'error': '', + } + + current_rel_path = self._normalize_cloud_path(unquote(relative_path or '')) + target_path = self._join_cloud_paths(cloud_root, current_rel_path) + + try: + client = WebdavFileSystem(dav_base, auth=(webdav_user, webdav_password)) + raw_entries = client.ls(target_path, detail=True) + except Exception as exc: + return { + 'enabled': False, + 'entries': [], + 'current_path': current_rel_path, + 'parent_path': '', + 'error': str(exc), + } + + entries = [] + for item in raw_entries: + if isinstance(item, dict): + full_name = self._normalize_cloud_path(item.get('name') or item.get('path') or '') + item_type = str(item.get('type') or '') + size = item.get('size') + else: + full_name = self._normalize_cloud_path(str(item)) + item_type = '' + size = None + + if not full_name or full_name == target_path: + continue + + if full_name.startswith(cloud_root): + relative_from_root = full_name[len(cloud_root):].lstrip('/') + else: + relative_from_root = full_name + + name = full_name.rstrip('/').split('/')[-1] + if not name: + continue + + is_dir = item_type.lower() in ('directory', 'dir') or full_name.endswith('/') + encoded_path = quote(relative_from_root) + nav_href = '/adportal/contracts/%s?media_path=%s' % (contract.uuid, encoded_path) + href = nav_href if is_dir else '/adportal/contracts/%s/media?path=%s' % (contract.uuid, encoded_path) + + entries.append({ + 'name': name, + 'is_dir': is_dir, + 'size': size, + 'href': href, + 'nav_href': nav_href, + 'nav_path': relative_from_root, + }) + + entries = sorted(entries, key=lambda entry: (not entry['is_dir'], entry['name'].lower())) + + parent_path = '' + if current_rel_path: + parent_path = posixpath.dirname(current_rel_path) + if parent_path == '.': + parent_path = '' + + return { + 'enabled': True, + 'entries': entries, + 'current_path': current_rel_path, + 'parent_path': parent_path, + 'parent_href': '/adportal/contracts/%s' % contract.uuid if not parent_path else '/adportal/contracts/%s?media_path=%s' % (contract.uuid, quote(parent_path)), + 'panel_url': '/adportal/contracts/%s/media/panel' % contract.uuid, + 'breadcrumbs': self._build_media_breadcrumbs(contract.uuid, current_rel_path), + 'error': '', + } + + def _build_media_values(self, contract, media_path=''): + try: + media_info = self._get_contract_media_entries(contract, media_path) + except Exception: + media_info = { + 'enabled': False, + 'entries': [], + 'current_path': '', + 'parent_path': '', + 'parent_href': '/adportal/contracts/%s' % contract.uuid, + 'panel_url': '/adportal/contracts/%s/media/panel' % contract.uuid, + 'breadcrumbs': self._build_media_breadcrumbs(contract.uuid, ''), + 'error': '', + } + + return { + 'contract': contract, + 'media_enabled': media_info['enabled'], + 'media_entries': media_info['entries'], + 'media_current_path': media_info['current_path'], + 'media_parent_path': media_info['parent_path'], + 'media_parent_href': media_info.get('parent_href', '/adportal/contracts/%s' % contract.uuid), + 'media_panel_url': media_info.get('panel_url', '/adportal/contracts/%s/media/panel' % contract.uuid), + 'media_breadcrumbs': media_info.get('breadcrumbs', self._build_media_breadcrumbs(contract.uuid, '')), + 'media_error': media_info['error'], + } + + @http.route(['/my'], type='http', auth='user', website=True, sitemap=False) + def adportal_my(self, **kwargs): + return request.redirect('/adportal/dashboard') + + @http.route(['/adportal/dashboard'], type='http', auth='user', website=True) + def adportal_dashboard(self, **kwargs): + user = request.env.user + contract_model = request.env['dss.contracts'].sudo() + can_show_contracts = self._user_has_portal_right(user, 'portal_right_contract_overview') + can_show_graphic_examples = self._user_has_portal_right(user, 'portal_right_graphic_examples') + + draft_domain = [ + ('contract_creator', '=', user.id), + ('web_contract_input_notfinished', '=', True), + ] + recent_domain = self._get_user_contract_visibility_domain(user) + + draft_count = 0 + total_count = 0 + recent_contracts = contract_model.browse([]) + if can_show_contracts: + draft_count = contract_model.search_count(draft_domain) + total_count = contract_model.search_count(recent_domain) + recent_contracts = contract_model.search(recent_domain, limit=8, order='write_date desc, id desc') + + settings = request.env['dss.settings'].sudo().search([], limit=1) + show_foreign_ranking_names = True if not settings else bool(settings.adportal_ranking_show_foreign_names) + + ranking_rows = [] + if self._user_has_portal_right(user, 'portal_right_ranking_view'): + ranking_profiles = request.env['dss.portal.user.profile'].sudo().search([ + ('ranking_enabled', '=', True), + ('user_id.active', '=', True), + ], order='ranking_sum desc, ranking_position asc, id asc') + + max_sum = 0.0 + for ranking_profile in ranking_profiles: + current_sum = float(ranking_profile.ranking_sum or 0.0) + if current_sum > max_sum: + max_sum = current_sum + + for ranking_profile in ranking_profiles: + ranking_user = ranking_profile.user_id + current_sum = float(ranking_profile.ranking_sum or 0.0) + if max_sum > 0 and current_sum > 0: + stars = int(round((current_sum / max_sum) * 8.0)) + stars = max(1, stars) + else: + stars = 0 + stars = max(0, min(8, stars)) + + display_name = ranking_user.name + if (not show_foreign_ranking_names) and ranking_user.id != user.id: + display_name = '*****' + + ranking_rows.append({ + 'name': display_name, + 'stars': stars, + 'sum': current_sum, + }) + + values = { + 'username': user.name, + 'draft_count': draft_count, + 'total_count': total_count, + 'recent_contracts': recent_contracts, + 'show_contract_overview': can_show_contracts, + 'show_graphic_examples': can_show_graphic_examples, + 'show_ranking': self._user_has_portal_right(user, 'portal_right_ranking_view'), + 'ranking_rows': ranking_rows, + 'page_name': 'adportal_dashboard', + } + return request.render('DigitalSignage.adportal_dashboard', values) + + @http.route(['/adportal/graphic-examples'], type='http', auth='user', website=True) + def adportal_graphic_examples(self, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_graphic_examples') + + graphic_templates = self._get_visible_graphic_templates() + suggestions = request.env['dss.contracts'].sudo().search_read( + [('client_short_company', '!=', False)], + ['client_short_company'], + limit=200, + order='write_date desc, id desc', + ) + company_suggestions = [] + seen = set() + for row in suggestions: + company_name = (row.get('client_short_company') or '').strip() + if not company_name: + continue + lowered = company_name.lower() + if lowered in seen: + continue + seen.add(lowered) + company_suggestions.append(company_name) + + values = { + 'username': user.name, + 'graphic_templates': graphic_templates, + 'company_suggestions': company_suggestions, + 'page_name': 'adportal_graphic_examples', + } + return request.render('DigitalSignage.adportal_graphic_examples', values) + + @http.route(['/adportal/graphic-examples/start'], type='http', auth='user', methods=['POST'], csrf=False) + def adportal_graphic_examples_start(self, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_graphic_examples') + + company_name = (kwargs.get('company') or '').strip() + selected_address = (kwargs.get('selected_address') or '').strip() + template_id = int(kwargs.get('template_id') or 0) + if not company_name: + _logger.info('No company name provided in graphic example start request') + return self._json_response({'ok': False, 'error': 'Bitte eine Firma eingeben.'}, status=400) + + settings = self._get_adportal_settings() + template = None + if template_id: + template = self._get_visible_graphic_templates().filtered(lambda item: item.id == template_id)[:1] + template = template[0] if template else None + + resolution = self._resolve_graphic_resolution(template) + _logger.info('Using resolution: %s', resolution) + + mediatype_prompt = (template.adportal_ki_prompt or '').strip() if template else '' + company_context, company_context_warning = self._get_company_context_from_google(settings, company_name) + + prompt, chatgpt_error = self._build_graphic_prompt_with_chatgpt( + settings, + company_name, + company_address=selected_address, + resolution_label=resolution['label'], + mediatype_name=template.medianame if template else 'Standard', + mediatype_prompt=mediatype_prompt, + company_context=company_context, + ) + payload = { + 'mode': 'start', + 'company': company_name, + 'prompt': prompt, + 'template_id': template.id if template else 0, + 'template_name': template.medianame if template else 'Standard', + 'resolution_width': resolution['width'], + 'resolution_height': resolution['height'], + 'resolution_label': resolution['label'], + } + result, error = self._call_graphics_engine(settings, payload) + if error: + return self._json_response({'ok': False, 'error': error}, status=502) + if chatgpt_error and (settings and settings.adportal_graphics_use_chatgpt): + payload = dict(payload) + payload['chatgpt_warning'] = chatgpt_error + + image_url, image_data = self._extract_image_from_ai_result(result) + if not image_url and not image_data: + return self._json_response({'ok': False, 'error': 'Kein Bild im API Ergebnis gefunden.'}, status=502) + + return self._json_response({ + 'ok': True, + 'status': 'OK', + 'company': company_name, + 'selected_address': selected_address, + 'prompt': prompt, + 'template_id': template.id if template else 0, + 'template_name': template.medianame if template else 'Standard', + 'resolution_width': resolution['width'], + 'resolution_height': resolution['height'], + 'resolution_label': resolution['label'], + 'chatgpt_warning': chatgpt_error if (settings and settings.adportal_graphics_use_chatgpt) else '', + 'google_warning': company_context_warning, + 'image_url': image_url, + 'image_data': image_data, + }) + + @http.route(['/adportal/graphic-examples/validate-address'], type='http', auth='user', methods=['POST'], csrf=False) + def adportal_graphic_examples_validate_address(self, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_graphic_examples') + + company_name = (kwargs.get('company') or '').strip() + if not company_name: + return self._json_response({'ok': False, 'error': 'Bitte eine Firma eingeben.'}, status=400) + + settings = self._get_adportal_settings() + is_valid, validation_error, addresses = self._validate_company_with_google(settings, company_name) + if not is_valid: + return self._json_response({'ok': False, 'error': validation_error}, status=400) + + selected_address = addresses[0] if len(addresses) == 1 else {} + return self._json_response({ + 'ok': True, + 'status': 'OK', + 'message': 'Adresse erfolgreich geprueft.', + 'requires_selection': len(addresses) > 1, + 'addresses': addresses, + 'selected_address': selected_address, + }) + + @http.route(['/adportal/graphic-examples/refine'], type='http', auth='user', methods=['POST'], csrf=False) + def adportal_graphic_examples_refine(self, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_graphic_examples') + + company_name = (kwargs.get('company') or '').strip() + selected_address = (kwargs.get('selected_address') or '').strip() + instruction = (kwargs.get('instruction') or '').strip() + current_image = (kwargs.get('current_image') or '').strip() + template_id = int(kwargs.get('template_id') or 0) + + if not company_name: + return self._json_response({'ok': False, 'error': 'Bitte eine Firma eingeben.'}, status=400) + if not instruction: + return self._json_response({'ok': False, 'error': 'Bitte eine Aenderung eingeben.'}, status=400) + + settings = self._get_adportal_settings() + template = None + if template_id: + template = self._get_visible_graphic_templates().filtered(lambda item: item.id == template_id)[:1] + template = template[0] if template else None + + resolution = self._resolve_graphic_resolution(template) + mediatype_prompt = (template.adportal_ki_prompt or '').strip() if template else '' + company_context, company_context_warning = self._get_company_context_from_google(settings, company_name) + prompt, chatgpt_error = self._build_graphic_prompt_with_chatgpt( + settings, + company_name, + company_address=selected_address, + instruction=instruction, + resolution_label=resolution['label'], + mediatype_name=template.medianame if template else 'Standard', + mediatype_prompt=mediatype_prompt, + company_context=company_context, + ) + payload = { + 'mode': 'refine', + 'company': company_name, + 'prompt': prompt, + 'instruction': instruction, + 'current_image': current_image, + 'template_id': template.id if template else 0, + 'template_name': template.medianame if template else 'Standard', + 'resolution_width': resolution['width'], + 'resolution_height': resolution['height'], + 'resolution_label': resolution['label'], + } + result, error = self._call_graphics_engine(settings, payload) + if error: + return self._json_response({'ok': False, 'error': error}, status=502) + if chatgpt_error and (settings and settings.adportal_graphics_use_chatgpt): + payload = dict(payload) + payload['chatgpt_warning'] = chatgpt_error + + image_url, image_data = self._extract_image_from_ai_result(result) + if not image_url and not image_data: + return self._json_response({'ok': False, 'error': 'Kein Bild im API Ergebnis gefunden.'}, status=502) + + return self._json_response({ + 'ok': True, + 'status': 'OK', + 'company': company_name, + 'selected_address': selected_address, + 'prompt': prompt, + 'template_id': template.id if template else 0, + 'template_name': template.medianame if template else 'Standard', + 'resolution_width': resolution['width'], + 'resolution_height': resolution['height'], + 'resolution_label': resolution['label'], + 'chatgpt_warning': chatgpt_error if (settings and settings.adportal_graphics_use_chatgpt) else '', + 'google_warning': company_context_warning, + 'image_url': image_url, + 'image_data': image_data, + }) + + @http.route(['/adportal/contracts'], type='http', auth='user', website=True) + def adportal_contracts(self, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_contract_overview') + + contract_model = request.env['dss.contracts'].sudo() + + preferred_domain = self._get_user_contract_visibility_domain(user, require_project=True) + fallback_domain = [('project', '!=', False)] + + grouped = contract_model.read_group(preferred_domain, ['project'], ['project']) + if not grouped: + grouped = contract_model.read_group(fallback_domain, ['project'], ['project']) + + count_by_project = {} + project_ids = [] + for row in grouped: + project_tuple = row.get('project') + if not project_tuple: + continue + project_id = project_tuple[0] + count_by_project[project_id] = row.get('project_count', row.get('__count', 0)) + project_ids.append(project_id) + + project_records = request.env['dss.projects'].sudo().browse(project_ids) + project_cards = [] + for project in sorted(project_records, key=lambda p: ((p.projectid or 0), p.id)): + project_cards.append({ + 'id': project.id, + 'projectid': project.projectid, + 'name': project.projektname or project.name or '-', + 'count': count_by_project.get(project.id, 0), + }) + + values = { + 'username': user.name, + 'project_cards': project_cards, + 'page_name': 'adportal_contracts', + } + return request.render('DigitalSignage.adportal_contracts', values) + + @http.route(['/adportal/contracts/project/'], type='http', auth='user', website=True) + def adportal_contracts_by_project(self, project_id, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_contract_overview') + + project = request.env['dss.projects'].sudo().browse(project_id) + if not project.exists(): + raise NotFound() + + contract_model = request.env['dss.contracts'].sudo() + preferred_domain = self._get_user_contract_visibility_domain(user, project_id=project.id) + fallback_domain = [('project', '=', project.id)] + + documents = contract_model.search( + preferred_domain, + order='write_date desc, id desc', + limit=200, + ) + if not documents: + documents = contract_model.search( + fallback_domain, + order='write_date desc, id desc', + limit=200, + ) + + computed_contract_end_by_id = {} + for document in documents: + _logger.warning('ADPORTAL: building computed_contract_end_by_id for contract id=%s', document.id) + computed_contract_end_by_id[document.id] = self._compute_contract_end_without_extension(document) + + values = { + 'username': user.name, + 'project': project, + 'documents': documents, + 'computed_contract_end_by_id': computed_contract_end_by_id, + 'page_name': 'adportal_contracts_project', + } + return request.render('DigitalSignage.adportal_contracts_project', values) + + @http.route(['/adportal/contracts/'], type='http', auth='user', website=True) + def adportal_contract_detail(self, uuid, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_contract_detail') + can_show_media = self._user_has_portal_right(user, 'portal_right_media_view') + can_show_graphic_examples = self._user_has_portal_right(user, 'portal_right_graphic_examples') + + contract_model = request.env['dss.contracts'].sudo() + contract = contract_model.search([ + ('uuid', '=', uuid), + ], limit=1) + + self._ensure_contract_portal_access(contract, contract_model, user) + + media_path = kwargs.get('media_path') or '' + media_values = self._build_media_values(contract, media_path) if can_show_media else { + 'contract': contract, + 'media_enabled': False, + 'media_entries': [], + 'media_current_path': '', + 'media_parent_path': '', + 'media_parent_href': '/adportal/contracts/%s' % contract.uuid, + 'media_panel_url': '/adportal/contracts/%s/media/panel' % contract.uuid, + 'media_breadcrumbs': [], + 'media_error': '', + } + + values = { + 'username': user.name, + 'contract': contract, + 'show_media': can_show_media, + 'show_graphic_examples': can_show_graphic_examples, + **media_values, + 'page_name': 'adportal_contract_detail', + } + return request.render('DigitalSignage.adportal_contract_detail', values) + + @http.route(['/adportal/contracts//media/panel'], type='http', auth='user', website=True) + def adportal_contract_media_panel(self, uuid, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_contract_detail') + self._ensure_portal_right(user, 'portal_right_media_view') + + contract_model = request.env['dss.contracts'].sudo() + contract = contract_model.search([ + ('uuid', '=', uuid), + ], limit=1) + self._ensure_contract_portal_access(contract, contract_model, user) + + media_values = self._build_media_values(contract, kwargs.get('media_path') or '') + return request.render('DigitalSignage.adportal_contract_media_panel', media_values) + + @http.route(['/adportal/contracts//media'], type='http', auth='user', website=True) + def adportal_contract_media_download(self, uuid, **kwargs): + user = request.env.user + self._ensure_portal_right(user, 'portal_right_contract_detail') + self._ensure_portal_right(user, 'portal_right_media_view') + + contract_model = request.env['dss.contracts'].sudo() + contract = contract_model.search([ + ('uuid', '=', uuid), + ], limit=1) + self._ensure_contract_portal_access(contract, contract_model, user) + + requested_rel_path = self._normalize_cloud_path(unquote(kwargs.get('path') or '')) + if not requested_rel_path: + raise NotFound() + + cloud_root = self._get_contract_cloud_root(contract) + if not cloud_root: + raise NotFound() + + absolute_path = self._join_cloud_paths(cloud_root, requested_rel_path) + dav_base, webdav_user, webdav_password = self._get_webdav_connection_data() + if not dav_base: + raise NotFound() + + try: + client = WebdavFileSystem(dav_base, auth=(webdav_user, webdav_password)) + info = client.info(absolute_path) + if str(info.get('type') or '').lower() in ('directory', 'dir'): + return request.redirect('/adportal/contracts/%s?media_path=%s' % (contract.uuid, quote(requested_rel_path))) + with client.open(absolute_path, 'rb') as stream: + data = stream.read() + except Exception: + raise NotFound() + + filename = absolute_path.split('/')[-1] or 'download' + mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + headers = [ + ('Content-Type', mimetype), + ('Content-Disposition', 'inline; filename="%s"' % filename), + ] + return request.make_response(data, headers=headers) diff --git a/controllers/ai_agent_controller.py b/controllers/ai_agent_controller.py new file mode 100755 index 0000000..849fead --- /dev/null +++ b/controllers/ai_agent_controller.py @@ -0,0 +1,2328 @@ +# -*- coding: utf-8 -*- + +import json +import logging +import os +import re +import ast +from datetime import datetime, timezone +from urllib.parse import quote + +import requests + +from odoo import http +from odoo.exceptions import AccessError +from odoo.http import request +from odoo.osv import expression + +try: + from zoneinfo import ZoneInfo +except Exception: + ZoneInfo = None + +_logger = logging.getLogger(__name__) + + +class AIAgentController(http.Controller): + + _MAX_CONTRACT_CONTEXT_CHARS = 40000 + _MAX_PROJECT_CONTEXT_CHARS = 30000 + _TOOL_MAX_ROWS = 100 + _OPEN_TABLE_ACTION_XMLIDS = { + 'dss.contracts': 'DigitalSignage.action_dss_main_contracts', + 'dss.projects': 'DigitalSignage.action_dss_view_tree', + } + _OPEN_TABLE_MENU_XMLIDS = { + 'dss.contracts': 'DigitalSignage.menu_dss_contracts_details', + 'dss.projects': 'DigitalSignage.menu_dss_recherche_project_details', + } + + def _log_params(self, event_name, params): + """Centralized structured logging with clipping to keep log lines readable.""" + try: + serialized = json.dumps(params or {}, ensure_ascii=False, default=str) + except Exception: + serialized = str(params) + _logger.info('AI Agent %s | params=%s', event_name, self._clip_text(serialized, 3000)) + + def _save_history_entry(self, role, content): + text = (content or '').strip() + if not text: + return + request.env['dss.ai.chat.history'].sudo().create({ + 'user_id': request.env.user.id, + 'role': role, + 'content': text, + }) + + def _save_exchange(self, cleaned_messages, assistant_reply): + last_user_prompt = self._last_user_query(cleaned_messages) + if last_user_prompt: + self._save_history_entry('user', last_user_prompt) + if assistant_reply: + self._save_history_entry('assistant', assistant_reply) + + @http.route('/digitalsignage/ai_agent/prompts', type='json', auth='user') + def ai_agent_prompts(self, limit=20): + safe_limit = self._safe_int(limit, default=20, min_value=1, max_value=100) + rows = request.env['dss.ai.chat.history'].sudo().search_read( + [('user_id', '=', request.env.user.id), ('role', '=', 'user')], + ['content'], + limit=200, + order='create_date desc', + ) + prompts = [] + seen = set() + for row in rows: + text = (row.get('content') or '').strip() + if not text: + continue + if text in seen: + continue + seen.add(text) + prompts.append(text) + if len(prompts) >= safe_limit: + break + return {'prompts': prompts} + + @http.route('/digitalsignage/ai_agent/open_contract', type='json', auth='user') + def ai_agent_open_contract(self, contract_auto_id='', record_id=None): + model_name = 'dss.contracts' + contract_auto_id = (contract_auto_id or '').strip() + record_id = self._safe_int(record_id or 0, default=0, min_value=0) + is_project_candidate = bool( + contract_auto_id + and contract_auto_id.isdigit() + and ( + contract_auto_id.endswith('00') + or len(contract_auto_id) == 3 + ) + ) + + self._log_params('navbar.open_contract.request', { + 'user_id': request.env.user.id, + 'contract_auto_id': contract_auto_id, + 'record_id': record_id, + 'is_project_candidate': is_project_candidate, + }) + + # If the input looks like a project number, first try an exact lookup on dss.projects.projectid. + if is_project_candidate: + project_number = self._safe_int(contract_auto_id, default=0, min_value=0) + if project_number: + try: + project_record = request.env['dss.projects'].search([ + ('projectid', '=', project_number), + ], limit=1) + except AccessError: + return { + 'error': 'Kein Zugriff auf dss.projects.', + 'found': False, + 'message': 'Kein Zugriff auf Projektdaten.', + } + + if project_record: + open_table = self._build_open_project_kanban_payload(project_record) + return { + 'found': True, + 'mode': 'project_contract_kanban', + 'record_id': int(project_record.id), + 'open_table': open_table, + 'message': 'Projekt-Vertragskanban ueber ProjectID geoeffnet.', + } + + model = request.env[model_name] + record = None + try: + if record_id > 0: + record = model.browse(record_id) + if not record.exists(): + record = None + if not record and contract_auto_id: + record = model.search( + ['|', ('contract_auto_id', '=', contract_auto_id), ('contract_id', '=', contract_auto_id)], + limit=1, + ) + except AccessError: + return { + 'error': 'Kein Zugriff auf dss.contracts.', + 'found': False, + 'message': 'Kein Zugriff auf Vertragsdaten.', + } + + # Important: contract lookup has priority, even if number ends with 00. + if record: + open_table = self._build_open_record_payload( + model_name, + int(record.id), + domain=[('id', '=', int(record.id))], + open_label='Vertrag im Formular oeffnen', + ) + + self._log_params('navbar.open_contract.response', { + 'user_id': request.env.user.id, + 'record_id': int(record.id), + 'mode': 'contract_form', + }) + + return { + 'found': True, + 'mode': 'contract_form', + 'record_id': int(record.id), + 'open_table': open_table, + 'message': 'Vertrag geoeffnet.', + } + + return { + 'found': False, + 'contract_auto_id': contract_auto_id, + 'message': 'Keine passenden Vertraege/Projekte fuer "%s" gefunden.' % contract_auto_id, + } + + @http.route('/digitalsignage/ai_agent/history', type='json', auth='user') + def ai_agent_history(self, limit=30): + safe_limit = self._safe_int(limit, default=30, min_value=1, max_value=200) + rows = request.env['dss.ai.chat.history'].sudo().search_read( + [('user_id', '=', request.env.user.id)], + ['role', 'content', 'create_date'], + limit=safe_limit, + order='create_date desc', + ) + rows.reverse() + return {'items': rows} + + def _clip_text(self, text, max_chars): + if not text: + return "" + if len(text) <= max_chars: + return text + return "%s\n... (Kontext gekuerzt, Zeichenlimit erreicht)" % text[:max_chars] + + def _find_action_for_model(self, model_name): + if not model_name: + return {'action_id': None, 'menu_id': None, 'view_type': 'tree'} + try: + action_model = request.env['ir.actions.act_window'].sudo() + action = None + menu = None + + preferred_xmlid = self._OPEN_TABLE_ACTION_XMLIDS.get(model_name) + if preferred_xmlid: + action = request.env.ref(preferred_xmlid, raise_if_not_found=False) + + preferred_menu_xmlid = self._OPEN_TABLE_MENU_XMLIDS.get(model_name) + if preferred_menu_xmlid: + menu = request.env.ref(preferred_menu_xmlid, raise_if_not_found=False) + + # Prefer actions that can open a list/tree view. + if not action: + action = action_model.search( + [('res_model', '=', model_name), ('view_mode', 'ilike', 'tree')], + order='id desc', + limit=1, + ) + if not action: + action = action_model.search( + [('res_model', '=', model_name), ('view_mode', 'ilike', 'list')], + order='id desc', + limit=1, + ) + if not action: + action = action_model.search( + [('res_model', '=', model_name)], + order='id desc', + limit=1, + ) + + menu_id = menu.id if menu else None + if action and not menu_id: + menu = request.env['ir.ui.menu'].sudo().search( + [('action', '=', 'ir.actions.act_window,%s' % action.id)], + order='id asc', + limit=1, + ) + menu_id = menu.id if menu else None + + return { + 'action_id': action.id if action else None, + 'menu_id': menu_id, + 'view_type': 'tree', + } + except Exception: + return {'action_id': None, 'menu_id': None, 'view_type': 'tree'} + + def _build_open_table_url(self, model_name, action_id, menu_id, view_type, domain): + if not model_name: + return '/web' + domain_str = quote(json.dumps(domain or [], ensure_ascii=False)) + parts = [] + company_ids = request.env.context.get('allowed_company_ids') or [request.env.company.id] + if action_id: + parts.append('action=%s' % action_id) + if menu_id: + parts.append('menu_id=%s' % menu_id) + if company_ids: + parts.append('cids=%s' % ','.join([str(cid) for cid in company_ids])) + parts.append('model=%s' % quote(str(model_name))) + parts.append('view_type=%s' % quote(str(view_type or 'tree'))) + parts.append('domain=%s' % domain_str) + return '/web#' + '&'.join(parts) + + def _build_open_table_action(self, model_name, domain): + if not model_name: + return None + if not isinstance(domain, list): + domain = [] + domain = self._normalize_domain(domain) + action_info = self._find_action_for_model(model_name) + action_id = action_info.get('action_id') + action_name = None + if action_id: + try: + action = request.env['ir.actions.act_window'].sudo().browse(action_id) + action_name = action.name or None + except Exception: + action_name = None + + action_context = { + 'ai_open_table_domain': domain, + } + action_context.update(self._domain_to_search_defaults(domain)) + + return { + 'type': 'ir.actions.act_window', + 'name': action_name or model_name, + 'res_model': model_name, + 'view_mode': 'tree,form', + 'views': [[False, 'tree'], [False, 'form']], + 'domain': domain, + 'target': 'current', + 'context': action_context, + } + + def _build_open_table_payload(self, model_name, domain, returned=0, total=0, record_ids=None): + if not model_name: + return None + if not isinstance(domain, list): + domain = [] + domain = self._normalize_domain(domain) + record_ids = [rid for rid in (record_ids or []) if isinstance(rid, int)] + action_info = self._find_action_for_model(model_name) + action_id = action_info.get('action_id') + menu_id = action_info.get('menu_id') + view_type = action_info.get('view_type') or 'tree' + return { + 'model': model_name, + 'action_id': action_id, + 'menu_id': menu_id, + 'view_type': view_type, + 'domain': domain, + 'record_ids': record_ids, + 'returned': returned, + 'total': total, + 'open_action': self._build_open_table_action(model_name, domain), + 'url': self._build_open_table_url(model_name, action_id, menu_id, view_type, domain), + } + + def _parse_domain_input(self, raw_domain): + if isinstance(raw_domain, tuple): + return [raw_domain] + + if isinstance(raw_domain, dict): + # Accept wrapper formats like {"domain": [...]}, {"filters": [...]}, {"conditions": [...]}. + for key in ('domain', 'filters', 'conditions'): + if key in raw_domain: + return self._parse_domain_input(raw_domain.get(key)) + + # Accept single-clause dict format: {"field": "x", "operator": "=", "value": 1} + field_name = raw_domain.get('field') + operator = raw_domain.get('operator', '=') + if field_name: + return [(field_name, operator, raw_domain.get('value'))] + return [] + + if isinstance(raw_domain, list): + # Also accept list of clause-dicts. + converted = [] + has_dict_clause = False + for item in raw_domain: + if isinstance(item, dict): + has_dict_clause = True + field_name = item.get('field') + operator = item.get('operator', '=') + if field_name: + converted.append((field_name, operator, item.get('value'))) + continue + converted.append(item) + if has_dict_clause: + return converted + return raw_domain + if not isinstance(raw_domain, str) or not raw_domain.strip(): + return [] + + text = raw_domain.strip() + + # Prefer JSON first because booleans/null are represented as true/false/null. + try: + parsed_json = json.loads(text) + parsed = self._parse_domain_input(parsed_json) + if isinstance(parsed, list): + return parsed + except Exception: + pass + + # Fallback for python-literal style domains. + try: + parsed_literal = ast.literal_eval(text) + parsed = self._parse_domain_input(parsed_literal) + if isinstance(parsed, list): + return parsed + except Exception: + pass + + _logger.warning('Invalid domain input: %s', raw_domain) + return [] + + def _open_table_priority(self, function_name): + priorities = { + 'open_contract_form': 110, + 'get_all_contracts': 100, + 'search_all_records': 90, + 'get_model_records': 80, + 'get_projects': 70, + } + return priorities.get(function_name or '', 0) + + def _build_open_record_action(self, model_name, record_id, view_mode='form'): + if not model_name or not isinstance(record_id, int) or record_id <= 0: + return None + action_info = self._find_action_for_model(model_name) + action_id = action_info.get('action_id') + action_name = None + if action_id: + try: + action = request.env['ir.actions.act_window'].sudo().browse(action_id) + action_name = action.name or None + except Exception: + action_name = None + + return { + 'type': 'ir.actions.act_window', + 'name': action_name or model_name, + 'res_model': model_name, + 'res_id': record_id, + 'view_mode': view_mode or 'form', + 'views': [[False, 'form']], + 'target': 'current', + } + + def _build_open_record_url(self, model_name, record_id, action_id=None, menu_id=None, view_type='form'): + if not model_name or not isinstance(record_id, int) or record_id <= 0: + return '/web' + parts = [] + company_ids = request.env.context.get('allowed_company_ids') or [request.env.company.id] + if action_id: + parts.append('action=%s' % action_id) + if menu_id: + parts.append('menu_id=%s' % menu_id) + if company_ids: + parts.append('cids=%s' % ','.join([str(cid) for cid in company_ids])) + parts.append('model=%s' % quote(str(model_name))) + parts.append('id=%s' % record_id) + parts.append('view_type=%s' % quote(str(view_type or 'form'))) + return '/web#' + '&'.join(parts) + + def _build_open_record_payload(self, model_name, record_id, domain=None, open_label='Datensatz oeffnen'): + if not model_name or not isinstance(record_id, int) or record_id <= 0: + return None + action_info = self._find_action_for_model(model_name) + action_id = action_info.get('action_id') + menu_id = action_info.get('menu_id') + return { + 'model': model_name, + 'action_id': action_id, + 'menu_id': menu_id, + 'view_type': 'form', + 'domain': self._normalize_domain(domain or [('id', '=', record_id)]), + 'record_ids': [record_id], + 'returned': 1, + 'total': 1, + 'auto_open': True, + 'open_label': open_label, + 'open_action': self._build_open_record_action(model_name, record_id, view_mode='form'), + 'url': self._build_open_record_url(model_name, record_id, action_id=action_id, menu_id=menu_id, view_type='form'), + } + + def _build_open_project_kanban_payload(self, project_record): + if not project_record or not project_record.exists(): + return None + + model_name = 'dss.contracts' + action_info = self._find_action_for_model(model_name) + action = request.env.ref('DigitalSignage.action_dss_project_contracts', raise_if_not_found=False) + action_id = action.id if action else action_info.get('action_id') + menu_id = action_info.get('menu_id') + domain = [('project', '=', int(project_record.id))] + + return { + 'model': model_name, + 'action_id': action_id, + 'menu_id': menu_id, + 'view_type': 'kanban', + 'domain': domain, + 'record_ids': [], + 'returned': 1, + 'total': 1, + 'auto_open': True, + 'open_label': 'Projekt-Vertraege in Kanban oeffnen', + 'open_action': { + 'type': 'ir.actions.act_window', + 'name': (action.name if action else None) or model_name, + 'res_model': model_name, + 'view_mode': 'kanban,form,tree,calendar,activity', + 'views': [[False, 'kanban'], [False, 'form'], [False, 'tree']], + 'domain': domain, + 'target': 'current', + 'context': { + 'ai_open_table_domain': domain, + 'default_project': int(project_record.id), + 'default_project_id': int(project_record.id), + 'show_project_update': True, + }, + }, + 'url': self._build_open_table_url(model_name, action_id, menu_id, 'kanban', domain), + } + + def _normalize_domain(self, domain): + if not isinstance(domain, list): + return [] + try: + return expression.normalize_domain(domain) + except Exception: + _logger.warning('Could not normalize domain, using raw domain: %s', domain) + return domain + + def _domain_to_search_defaults(self, domain): + """Best effort for visible UI filters via search_default_* context keys.""" + defaults = {} + if not isinstance(domain, list): + return defaults + for token in domain: + if not isinstance(token, (list, tuple)) or len(token) != 3: + continue + field_name, operator, value = token + if operator != '=': + continue + if not isinstance(field_name, str): + continue + if not field_name.replace('_', '').isalnum(): + continue + defaults['search_default_%s' % field_name] = value + return defaults + + def _extract_api_error_message(self, response): + try: + payload = response.json() or {} + error = payload.get('error') or {} + message = error.get('message') or '' + if message: + return message + except Exception: + pass + return response.text or '' + + def _parse_allowed_models(self, settings): + raw_models = (settings.ai_agent_callback_models or '').strip() if settings else '' + if not raw_models: + return [] + return [m.strip() for m in raw_models.split(',') if m.strip()] + + def _parse_allowed_fields(self, settings): + raw = (settings.ai_agent_callback_fields_json or '').strip() if settings else '' + if not raw: + return {} + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + _logger.warning('Invalid JSON in ai_agent_callback_fields_json') + return {} + + def _parse_allowed_aliases(self, settings): + raw = (settings.ai_agent_callback_aliases_json or '').strip() if settings else '' + if not raw: + return {} + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + _logger.warning('Invalid JSON in ai_agent_callback_aliases_json') + return {} + + def _parse_allowed_write_fields(self, settings): + raw = (settings.ai_agent_callback_write_fields or '').strip() if settings else '' + if not raw: + return {} + + allowed = {} + for item in re.split(r'[\n,;]+', raw): + entry = (item or '').strip() + if not entry or '.' not in entry: + continue + model_name, field_name = entry.rsplit('.', 1) + model_name = model_name.strip() + field_name = field_name.strip() + if not model_name or not field_name: + continue + allowed.setdefault(model_name, set()).add(field_name) + return allowed + + def _sanitize_search_text(self, value): + text = (value or '').strip() + # Keep search text compact to avoid overly broad / expensive queries. + return re.sub(r'\s+', ' ', text)[:120] + + def _normalize_contract_search_field(self, settings, model_name, field_name): + raw_original = (field_name or '').strip() + raw = raw_original.lower() + + allowed_fields_map = self._parse_allowed_fields(settings) + configured_fields = allowed_fields_map.get(model_name) or [] + allowed_fields = [f for f in configured_fields if isinstance(f, str) and f] + allowed_set = set(allowed_fields) + + if raw in allowed_fields: + return raw + + aliases_map = self._parse_allowed_aliases(settings) + model_aliases = aliases_map.get(model_name) if isinstance(aliases_map, dict) else {} + if not isinstance(model_aliases, dict): + model_aliases = {} + + normalized_aliases = {} + for alias_key, field_value in model_aliases.items(): + alias_key_text = str(alias_key or '').strip().lower() + field_text = str(field_value or '').strip() + if alias_key_text and field_text: + normalized_aliases[alias_key_text] = field_text + + target_field = normalized_aliases.get(raw, '') + if target_field in allowed_set: + return target_field + return '' + + def _to_boolean_search_value(self, value): + raw = (value or '').strip().lower() + if raw in ('1', 'true', 't', 'yes', 'y', 'ja', 'j', 'on'): + return True + if raw in ('0', 'false', 'f', 'no', 'n', 'nein', 'off'): + return False + return None + + def _split_field_and_value(self, settings, model_name, search_text): + text = self._sanitize_search_text(search_text) + for separator in (':', '='): + if separator in text: + left, right = text.split(separator, 1) + field_name = self._normalize_contract_search_field(settings, model_name, left) + value = self._sanitize_search_text(right) + if field_name and value: + return field_name, value + return '', text + + def _tool_search_all_records(self, settings, arguments): + """Durchsucht ein freigegebenes Modell nach einem Filterstring und gibt alle + gefundenen Datensaetze (paginiert) mit allen freigegebenen Feldern zurueck.""" + model_name = ((arguments or {}).get('model_name') or '').strip() + search_text = self._sanitize_search_text((arguments or {}).get('search_text') or '') + limit = self._safe_int((arguments or {}).get('limit', 50), default=50, min_value=1, max_value=self._TOOL_MAX_ROWS) + offset = self._safe_int((arguments or {}).get('offset', 0), default=0, min_value=0) + search_fields_arg = (arguments or {}).get('search_fields') or [] + + self._log_params('tool.search_all_records.request', { + 'user_id': request.env.user.id, + 'model_name': model_name, + 'search_text': search_text, + 'search_fields_arg': search_fields_arg, + 'limit': limit, + 'offset': offset, + }) + + allowed_models = self._parse_allowed_models(settings) + allowed_fields_map = self._parse_allowed_fields(settings) + + if model_name not in allowed_models: + return {'error': 'Modell ist nicht fuer Callback freigegeben: %s' % model_name} + + allowed_fields = allowed_fields_map.get(model_name) or [] + if not isinstance(allowed_fields, list) or not allowed_fields: + return {'error': 'Keine freigegebenen Felder fuer Modell %s konfiguriert.' % model_name} + + # Determine which fields to search in. + if search_fields_arg and isinstance(search_fields_arg, list): + search_target_fields = [f for f in search_fields_arg if f in allowed_fields] + else: + search_target_fields = allowed_fields[:] + + if not search_target_fields: + search_target_fields = allowed_fields[:] + + domain = [] + if search_text: + if len(search_target_fields) == 1: + domain = [(search_target_fields[0], 'ilike', search_text)] + else: + domain = [] + for idx, fname in enumerate(search_target_fields): + if idx < len(search_target_fields) - 1: + domain.append('|') + domain.append((fname, 'ilike', search_text)) + + total = request.env[model_name].search_count(domain) + rows = request.env[model_name].search_read( + domain, + allowed_fields, + limit=limit, + offset=offset, + order='write_date desc', + ) + + self._log_params('tool.search_all_records.response', { + 'user_id': request.env.user.id, + 'model_name': model_name, + 'total': total, + 'returned': len(rows), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(rows)) < total, + }) + + return { + 'model': model_name, + 'domain': domain, + 'search_text': search_text, + 'search_fields': search_target_fields, + 'total': total, + 'returned': len(rows), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(rows)) < total, + 'next_offset': offset + len(rows), + 'records': rows, + } + + def _tool_get_model_records(self, settings, arguments): + model_name = ((arguments or {}).get('model_name') or '').strip() + limit = self._safe_int((arguments or {}).get('limit', 25), default=25, min_value=1, max_value=self._TOOL_MAX_ROWS) + offset = self._safe_int((arguments or {}).get('offset', 0), default=0, min_value=0) + search_text = self._sanitize_search_text((arguments or {}).get('search_text') or '') + requested_fields = (arguments or {}).get('fields') or [] + + self._log_params('tool.get_model_records.request', { + 'user_id': request.env.user.id, + 'model_name': model_name, + 'search_text': search_text, + 'requested_fields': requested_fields, + 'limit': limit, + 'offset': offset, + }) + + allowed_models = self._parse_allowed_models(settings) + allowed_fields_map = self._parse_allowed_fields(settings) + + if model_name not in allowed_models: + return {'error': 'Modell ist nicht fuer Callback freigegeben: %s' % model_name} + + allowed_fields = allowed_fields_map.get(model_name) or [] + if not isinstance(allowed_fields, list): + allowed_fields = [] + if not allowed_fields: + return {'error': 'Keine freigegebenen Felder fuer Modell %s konfiguriert.' % model_name} + + if requested_fields and isinstance(requested_fields, list): + fields_to_read = [f for f in requested_fields if f in allowed_fields] + if not fields_to_read: + fields_to_read = allowed_fields[:] + else: + fields_to_read = allowed_fields[:] + + domain = [] + if search_text: + # Build OR domain over char/text-like configured fields. + or_fields = [f for f in allowed_fields if isinstance(f, str)] + if or_fields: + if len(or_fields) == 1: + domain = [(or_fields[0], 'ilike', search_text)] + else: + domain = [] + for idx, field_name in enumerate(or_fields): + if idx < len(or_fields) - 1: + domain.append('|') + domain.append((field_name, 'ilike', search_text)) + + total = request.env[model_name].search_count(domain) + rows = request.env[model_name].search_read( + domain, + fields_to_read, + limit=limit, + offset=offset, + order='write_date desc', + ) + + self._log_params('tool.get_model_records.response', { + 'user_id': request.env.user.id, + 'model_name': model_name, + 'fields_to_read': fields_to_read, + 'total': total, + 'returned': len(rows), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(rows)) < total, + }) + + return { + 'model': model_name, + 'domain': domain, + 'fields': fields_to_read, + 'total': total, + 'returned': len(rows), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(rows)) < total, + 'next_offset': offset + len(rows), + 'records': rows, + } + + def _tool_get_all_contracts(self, settings, arguments): + if not isinstance(arguments, dict): + self._log_params('tool.get_all_contracts.invalid_arguments', { + 'user_id': request.env.user.id, + 'type': type(arguments).__name__, + 'value': arguments, + }) + arguments = {} + + model_name = 'dss.contracts' + raw_domain = arguments.get('domain') + if raw_domain is None: + raw_domain = arguments.get('filters') + if raw_domain is None: + raw_domain = arguments.get('conditions') + + raw_search_field = arguments.get('search_field') + raw_search_value = arguments.get('search_value') or arguments.get('search_text') + requested_fields = arguments.get('fields') or [] + limit = self._safe_int(arguments.get('limit', 25), default=25, min_value=1, max_value=self._TOOL_MAX_ROWS) + offset = self._safe_int(arguments.get('offset', 0), default=0, min_value=0) + + self._log_params('tool.get_all_contracts.request', { + 'user_id': request.env.user.id, + 'domain': raw_domain, + 'search_field': raw_search_field, + 'search_value': raw_search_value, + 'requested_fields': requested_fields, + 'limit': limit, + 'offset': offset, + }) + + default_fields_to_read = [ + 'contract_auto_id', + 'contract_id', + 'contract_name', + 'contract_auto_name', + 'client_short_company', + 'client_short_vorname', + 'client_short_name', + 'contract_state', + 'project', + 'vertragssumme', + ] + + allowed_fields_map = self._parse_allowed_fields(settings) + configured_fields = allowed_fields_map.get(model_name) or [] + allowed_fields = [f for f in configured_fields if isinstance(f, str) and f] + + if requested_fields and isinstance(requested_fields, list): + fields_to_read = [f for f in requested_fields if f in allowed_fields] + if not fields_to_read: + return { + 'error': 'Keine der angeforderten Felder ist fuer %s freigegeben.' % model_name, + 'allowed_fields': allowed_fields, + } + else: + fields_to_read = default_fields_to_read + + domain = self._normalize_domain(self._parse_domain_input(raw_domain)) + + # Fallback: build a domain from search_field/search_text style inputs when no domain was passed. + if not domain: + normalized_search_field = self._normalize_contract_search_field(settings, model_name, raw_search_field) + search_text = self._sanitize_search_text(raw_search_value) + if search_text: + if normalized_search_field: + boolean_value = self._to_boolean_search_value(search_text) + if boolean_value is not None: + domain = [(normalized_search_field, '=', boolean_value)] + else: + domain = [(normalized_search_field, 'ilike', search_text)] + else: + split_field, split_value = self._split_field_and_value(settings, model_name, search_text) + if split_field and split_value: + boolean_value = self._to_boolean_search_value(split_value) + if boolean_value is not None: + domain = [(split_field, '=', boolean_value)] + else: + domain = [(split_field, 'ilike', split_value)] + + if not domain and search_text: + allowed_fields_map = self._parse_allowed_fields(settings) + configured_fields = allowed_fields_map.get(model_name) or [] + search_fields = [f for f in configured_fields if isinstance(f, str) and f] + if search_fields: + if len(search_fields) == 1: + domain = [(search_fields[0], 'ilike', search_text)] + else: + domain = [] + for idx, field_name in enumerate(search_fields): + if idx < len(search_fields) - 1: + domain.append('|') + domain.append((field_name, 'ilike', search_text)) + + domain = self._normalize_domain(domain) + + self._log_params('tool.get_all_contracts.domain_effective', { + 'user_id': request.env.user.id, + 'domain': domain, + }) + + records = request.env[model_name].search( + domain, + offset=offset, + limit=limit if limit > 0 else None, + order='write_date desc', + ) + total = request.env[model_name].search_count(domain) + rows = records.read(fields_to_read) + record_ids = records.ids + + contracts = [] + for row in rows: + state = row.get('contract_state') + project = row.get('project') + contracts.append({ + 'contract_auto_id': row.get('contract_auto_id') or row.get('contract_id') or '', + 'contract_name': row.get('contract_name') or row.get('contract_auto_name') or '', + 'customer': row.get('client_short_company') or ' '.join( + filter(None, [row.get('client_short_vorname'), row.get('client_short_name')]) + ).strip(), + 'state': state[1] if isinstance(state, (list, tuple)) and len(state) > 1 else '', + 'project': project[1] if isinstance(project, (list, tuple)) and len(project) > 1 else '', + 'amount': row.get('vertragssumme') if row.get('vertragssumme') not in (None, False) else None, + }) + + self._log_params('tool.get_all_contracts.response', { + 'user_id': request.env.user.id, + 'fields_to_read': fields_to_read, + 'total': total, + 'returned': len(contracts), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(contracts)) < total, + }) + + if requested_fields and isinstance(requested_fields, list): + return { + 'model': model_name, + 'domain': domain, + 'record_ids': record_ids, + 'fields': fields_to_read, + 'total': total, + 'returned': len(rows), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(rows)) < total, + 'next_offset': offset + len(rows), + 'records': rows, + } + + return { + 'model': model_name, + 'domain': domain, + 'record_ids': record_ids, + 'fields': fields_to_read, + 'total': total, + 'returned': len(contracts), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(contracts)) < total, + 'next_offset': offset + len(contracts), + 'contracts': contracts, + } + + def _tool_get_contract_by_number(self, arguments): + number = ((arguments or {}).get('contract_auto_id') or '').strip() + self._log_params('tool.get_contract_by_number.request', { + 'user_id': request.env.user.id, + 'contract_auto_id': number, + }) + if not number: + return {'error': 'contract_auto_id ist erforderlich.'} + + rows = request.env['dss.contracts'].search_read( + ['|', ('contract_auto_id', '=', number), ('contract_id', '=', number)], + [ + 'contract_auto_id', 'contract_id', 'contract_name', 'contract_auto_name', + 'client_short_company', 'client_short_vorname', 'client_short_name', + 'contract_state', 'project', 'vertragssumme', 'remark', 'contract_remark' + ], + limit=1, + ) + if not rows: + self._log_params('tool.get_contract_by_number.response', { + 'user_id': request.env.user.id, + 'contract_auto_id': number, + 'found': False, + }) + return {'found': False, 'contract_auto_id': number} + + row = rows[0] + state = row.get('contract_state') + project = row.get('project') + self._log_params('tool.get_contract_by_number.response', { + 'user_id': request.env.user.id, + 'contract_auto_id': number, + 'found': True, + }) + return { + 'found': True, + 'contract': { + 'contract_auto_id': row.get('contract_auto_id') or row.get('contract_id') or '', + 'contract_name': row.get('contract_name') or row.get('contract_auto_name') or '', + 'customer': row.get('client_short_company') or ' '.join( + filter(None, [row.get('client_short_vorname'), row.get('client_short_name')]) + ).strip(), + 'state': state[1] if isinstance(state, (list, tuple)) and len(state) > 1 else '', + 'project': project[1] if isinstance(project, (list, tuple)) and len(project) > 1 else '', + 'amount': row.get('vertragssumme') if row.get('vertragssumme') not in (None, False) else None, + 'remark': (row.get('remark') or '')[:2000], + 'contract_remark': (row.get('contract_remark') or '')[:2000], + }, + } + + def _tool_open_contract_form(self, arguments): + if not isinstance(arguments, dict): + arguments = {} + + model_name = 'dss.contracts' + record_id = self._safe_int(arguments.get('record_id', 0), default=0, min_value=0) + contract_auto_id = ((arguments or {}).get('contract_auto_id') or '').strip() + + self._log_params('tool.open_contract_form.request', { + 'user_id': request.env.user.id, + 'record_id': record_id, + 'contract_auto_id': contract_auto_id, + }) + + model = request.env[model_name] + record = None + + if record_id > 0: + record = model.browse(record_id) + if not record.exists(): + return {'error': 'Vertrag mit ID %s wurde nicht gefunden.' % record_id} + elif contract_auto_id: + domain = ['|', ('contract_auto_id', '=', contract_auto_id), ('contract_id', '=', contract_auto_id)] + record = model.search(domain, limit=1) + if not record: + return {'error': 'Vertrag %s wurde nicht gefunden.' % contract_auto_id} + else: + return {'error': 'record_id oder contract_auto_id ist erforderlich.'} + + open_table = self._build_open_record_payload( + model_name, + int(record.id), + domain=[('id', '=', int(record.id))], + open_label='Vertrag im Formular oeffnen', + ) + + result = { + 'opened': True, + 'model': model_name, + 'record_id': int(record.id), + 'record_ids': [int(record.id)], + 'domain': [('id', '=', int(record.id))], + 'returned': 1, + 'total': 1, + 'open_table': open_table, + } + + self._log_params('tool.open_contract_form.response', { + 'user_id': request.env.user.id, + 'record_id': int(record.id), + 'has_open_table': bool(open_table), + }) + return result + + def _tool_get_projects(self, arguments): + search_text = (arguments or {}).get('search_text') or '' + limit = self._safe_int((arguments or {}).get('limit', 25), default=25, min_value=1, max_value=self._TOOL_MAX_ROWS) + offset = self._safe_int((arguments or {}).get('offset', 0), default=0, min_value=0) + + self._log_params('tool.get_projects.request', { + 'user_id': request.env.user.id, + 'search_text': search_text, + 'limit': limit, + 'offset': offset, + }) + + domain = [] + if search_text: + domain = [ + '|', + '|', + ('projektname', 'ilike', search_text), + ('name', 'ilike', search_text), + ('projectid', 'ilike', search_text), + ] + + total = request.env['dss.projects'].search_count(domain) + rows = request.env['dss.projects'].search_read( + domain, + ['projectid', 'projektname', 'name', 'standort_strasse', 'standort_plz', 'standort_ort', 'maps_visitors', 'aktstatus'], + limit=limit, + offset=offset, + order='write_date desc', + ) + + projects = [] + for row in rows: + status = row.get('aktstatus') + projects.append({ + 'projectid': row.get('projectid') or '', + 'projektname': row.get('projektname') or row.get('name') or '', + 'location': ' '.join(filter(None, [row.get('standort_strasse'), row.get('standort_plz'), row.get('standort_ort')])), + 'visitors': row.get('maps_visitors') if row.get('maps_visitors') not in (None, False) else None, + 'status': status[1] if isinstance(status, (list, tuple)) and len(status) > 1 else '', + }) + + self._log_params('tool.get_projects.response', { + 'user_id': request.env.user.id, + 'total': total, + 'returned': len(projects), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(projects)) < total, + }) + + return { + 'model': 'dss.projects', + 'domain': domain, + 'total': total, + 'returned': len(projects), + 'offset': offset, + 'limit': limit, + 'has_more': (offset + len(projects)) < total, + 'next_offset': offset + len(projects), + 'projects': projects, + } + + def _validate_write_values(self, settings, model_name, values): + if not model_name: + return {'error': 'model_name ist erforderlich.'} + if not isinstance(values, dict) or not values: + return {'error': 'values muss ein nicht-leeres Objekt sein.'} + + allowed_write_fields = self._parse_allowed_write_fields(settings) + allowed_fields = allowed_write_fields.get(model_name) or set() + if not allowed_fields: + return {'error': 'Keine freigegebenen Schreibfelder fuer Modell %s konfiguriert.' % model_name} + + disallowed_fields = sorted([field_name for field_name in values.keys() if field_name not in allowed_fields]) + if disallowed_fields: + return { + 'error': 'Nicht freigegebene Schreibfelder fuer %s: %s' % (model_name, ', '.join(disallowed_fields)), + 'allowed_fields': sorted(list(allowed_fields)), + } + + model = request.env[model_name] + existing_fields = getattr(model, '_fields', {}) or {} + invalid_fields = sorted([field_name for field_name in values.keys() if field_name not in existing_fields]) + if invalid_fields: + return { + 'error': 'Unbekannte Felder fuer %s: %s' % (model_name, ', '.join(invalid_fields)), + } + return {'model': model} + + def _tool_create_model_record(self, settings, arguments): + if not isinstance(arguments, dict): + arguments = {} + + model_name = ((arguments or {}).get('model_name') or '').strip() + values = (arguments or {}).get('values') or {} + + self._log_params('tool.create_model_record.request', { + 'user_id': request.env.user.id, + 'model_name': model_name, + 'value_keys': sorted(list(values.keys())) if isinstance(values, dict) else [], + }) + + validation = self._validate_write_values(settings, model_name, values) + if validation.get('error'): + return validation + + model = validation['model'] + record = model.create(values) + result = { + 'created': True, + 'model': model_name, + 'record_id': record.id, + 'display_name': getattr(record, 'display_name', str(record.id)), + 'written_fields': sorted(list(values.keys())), + } + + self._log_params('tool.create_model_record.response', { + 'user_id': request.env.user.id, + 'model_name': model_name, + 'record_id': record.id, + 'written_fields': result['written_fields'], + }) + return result + + def _tool_update_model_record(self, settings, arguments): + if not isinstance(arguments, dict): + arguments = {} + + model_name = ((arguments or {}).get('model_name') or '').strip() + record_id = self._safe_int((arguments or {}).get('record_id'), default=0, min_value=0) + values = (arguments or {}).get('values') or {} + + self._log_params('tool.update_model_record.request', { + 'user_id': request.env.user.id, + 'model_name': model_name, + 'record_id': record_id, + 'value_keys': sorted(list(values.keys())) if isinstance(values, dict) else [], + }) + + if record_id <= 0: + return {'error': 'record_id ist erforderlich.'} + + validation = self._validate_write_values(settings, model_name, values) + if validation.get('error'): + return validation + + model = validation['model'] + record = model.browse(record_id) + if not record.exists(): + return { + 'error': 'Datensatz %s in %s wurde nicht gefunden.' % (record_id, model_name), + } + + record.write(values) + result = { + 'updated': True, + 'model': model_name, + 'record_id': record.id, + 'display_name': getattr(record, 'display_name', str(record.id)), + 'written_fields': sorted(list(values.keys())), + } + + self._log_params('tool.update_model_record.response', { + 'user_id': request.env.user.id, + 'model_name': model_name, + 'record_id': record.id, + 'written_fields': result['written_fields'], + }) + return result + + def _tool_get_system_datetime(self, arguments): + if not isinstance(arguments, dict): + arguments = {} + + requested_timezone = (arguments.get('timezone') or '').strip() + user_timezone = (request.env.user.tz or '').strip() or 'UTC' + timezone_name = requested_timezone or user_timezone + + self._log_params('tool.get_system_datetime.request', { + 'user_id': request.env.user.id, + 'requested_timezone': requested_timezone, + 'user_timezone': user_timezone, + }) + + tz_warning = '' + tzinfo = timezone.utc + effective_timezone = 'UTC' + + if ZoneInfo: + try: + tzinfo = ZoneInfo(timezone_name) + effective_timezone = timezone_name + except Exception: + tz_warning = 'Ungueltige Zeitzone: %s. Fallback auf UTC.' % timezone_name + tzinfo = timezone.utc + effective_timezone = 'UTC' + else: + if timezone_name and timezone_name != 'UTC': + tz_warning = 'ZoneInfo nicht verfuegbar. Fallback auf UTC.' + + now_utc = datetime.now(timezone.utc) + now_local = now_utc.astimezone(tzinfo) + + weekday_names_de = { + 1: 'Montag', + 2: 'Dienstag', + 3: 'Mittwoch', + 4: 'Donnerstag', + 5: 'Freitag', + 6: 'Samstag', + 7: 'Sonntag', + } + + result = { + 'timezone': effective_timezone, + 'iso_datetime': now_local.isoformat(), + 'date': now_local.strftime('%Y-%m-%d'), + 'time': now_local.strftime('%H:%M:%S'), + 'year': now_local.year, + 'month': now_local.month, + 'day': now_local.day, + 'weekday_number_iso': now_local.isoweekday(), + 'weekday_name': weekday_names_de.get(now_local.isoweekday(), ''), + 'week_number_iso': int(now_local.strftime('%V')), + 'timestamp_unix': int(now_local.timestamp()), + 'utc_iso_datetime': now_utc.isoformat(), + } + if tz_warning: + result['warning'] = tz_warning + + self._log_params('tool.get_system_datetime.response', { + 'user_id': request.env.user.id, + 'timezone': result.get('timezone'), + 'iso_datetime': result.get('iso_datetime'), + 'warning': result.get('warning', ''), + }) + + return result + + def _build_openai_tools(self): + self._log_params('tools.build_openai_tools', { + 'tool_names': [ + 'open_contract_form', + 'get_all_contracts', + 'get_contract_by_number', + 'get_projects', + 'create_model_record', + 'update_model_record', + 'get_system_datetime', + 'search_all_records', + 'get_model_records', + ] + }) + return [ + { + 'type': 'function', + 'function': { + 'name': 'open_contract_form', + 'description': 'Oeffnet einen spezifischen Vertrag in der Formularansicht.', + 'parameters': { + 'type': 'object', + 'properties': { + 'record_id': { + 'type': 'integer', + 'description': 'Interne Datensatz-ID von dss.contracts.', + 'minimum': 1, + }, + 'contract_auto_id': { + 'type': 'string', + 'description': 'Vertragsnummer (contract_auto_id oder contract_id) als Alternative zu record_id.', + }, + }, + 'additionalProperties': False, + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'get_all_contracts', + 'description': 'Sucht Vertraege in dss.contracts (paginiert).', + 'parameters': { + 'type': 'object', + 'properties': { + 'domain': { + 'type': 'array', + 'items': {}, + 'description': 'Optionaler Odoo-Domain-Array, z.B. [["contract_name", "ilike", "Test"], ["contract_auto_extend", "=", false]].', + }, + 'limit': { + 'type': 'integer', + 'description': 'Maximale Anzahl Datensaetze pro Seite.', + 'minimum': 1, + }, + 'offset': { + 'type': 'integer', + 'description': 'Startposition fuer Paging.', + 'minimum': 0, + }, + 'fields': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Optional: Rueckgabefelder fuer dss.contracts. Es sind nur freigegebene Felder aus den Einstellungen erlaubt.', + }, + }, + 'additionalProperties': False, + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'get_contract_by_number', + 'description': 'Liefert Vertragsdetails zu einer Vertragsnummer.', + 'parameters': { + 'type': 'object', + 'properties': { + 'contract_auto_id': { + 'type': 'string', + 'description': 'Vertragsnummer (contract_auto_id oder contract_id).', + }, + }, + 'required': ['contract_auto_id'], + 'additionalProperties': False, + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'get_all_projects', + 'description': 'Sucht Daten aus Projekte in dss.projects (paginiert).', + 'parameters': { + 'type': 'object', + 'properties': { + 'search_text': { + 'type': 'string', + 'description': 'Optionaler Filter fuer Projektname oder Projekt-ID.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Maximale Anzahl Datensaetze pro Seite.', + 'minimum': 1, + }, + 'offset': { + 'type': 'integer', + 'description': 'Startposition fuer Paging.', + 'minimum': 0, + }, + }, + 'additionalProperties': False, + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'create_model_record', + 'description': 'Legt einen Datensatz in einem Odoo-Modell an. Es duerfen nur exakt in den Einstellungen freigegebene Schreibfelder im Muster model.field gesetzt werden.', + 'parameters': { + 'type': 'object', + 'properties': { + 'model_name': { + 'type': 'string', + 'description': 'Name des Odoo-Modells, in dem ein Datensatz angelegt werden soll.', + }, + 'values': { + 'type': 'object', + 'description': 'Feldwerte fuer den neuen Datensatz. Nur exakt freigegebene Felder des Modells sind erlaubt.', + 'additionalProperties': True, + }, + }, + 'required': ['model_name', 'values'], + 'additionalProperties': False, + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'update_model_record', + 'description': 'Aendert einen vorhandenen Datensatz in einem Odoo-Modell. Es duerfen nur exakt in den Einstellungen freigegebene Schreibfelder im Muster model.field gesetzt werden.', + 'parameters': { + 'type': 'object', + 'properties': { + 'model_name': { + 'type': 'string', + 'description': 'Name des Odoo-Modells, in dem ein Datensatz geaendert werden soll.', + }, + 'record_id': { + 'type': 'integer', + 'description': 'ID des vorhandenen Datensatzes.', + 'minimum': 1, + }, + 'values': { + 'type': 'object', + 'description': 'Zu aendernde Feldwerte. Nur exakt freigegebene Felder des Modells sind erlaubt.', + 'additionalProperties': True, + }, + }, + 'required': ['model_name', 'record_id', 'values'], + 'additionalProperties': False, + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'get_system_datetime', + 'description': 'Liefert aktuelle Systemzeitparameter wie Datum, Uhrzeit, Wochentag, Jahr und ISO-Kalenderwoche.', + 'parameters': { + 'type': 'object', + 'properties': { + 'timezone': { + 'type': 'string', + 'description': 'Optionale IANA-Zeitzone (z.B. Europe/Berlin). Ohne Wert wird die Benutzer-Zeitzone verwendet.', + }, + }, + 'additionalProperties': False, + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'search_all_records', + 'description': ( + 'Durchsucht ein freigegebenes Odoo-Modell nach einem Filterstring und liefert ' + 'alle gefundenen Datensaetze mit allen freigegebenen Feldern (paginiert). ' + 'Nutze diese Funktion fuer breite Suchen, z.B. alle Vertraege eines Kunden.' + ), + 'parameters': { + 'type': 'object', + 'properties': { + 'model_name': { + 'type': 'string', + 'description': 'Name des freigegebenen Odoo-Modells, z.B. dss.contracts.', + }, + 'search_text': { + 'type': 'string', + 'description': 'Pflichtfeld: Suchbegriff fuer ilike-Filter ueber alle freigegebenen Felder.', + }, + 'search_fields': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Optional: Einschraenkung auf bestimmte Felder fuer die Suche.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Max. Datensaetze pro Seite (Standard 50).', + 'minimum': 1, + }, + 'offset': { + 'type': 'integer', + 'description': 'Startposition fuer Paging.', + 'minimum': 0, + }, + }, + 'required': ['model_name', 'search_text'], + 'additionalProperties': False, + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': 'get_model_records', + 'description': 'Generischer Callback fuer freigegebene Modelle/Felder laut Einstellungen.', + 'parameters': { + 'type': 'object', + 'properties': { + 'model_name': { + 'type': 'string', + 'description': 'Name des freigegebenen Odoo-Modells, z.B. dss.contracts.', + }, + 'fields': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Optional: Teilmenge der freigegebenen Felder.', + }, + 'search_text': { + 'type': 'string', + 'description': 'Optionaler Suchtext fuer ilike-Filter.', + }, + 'limit': { + 'type': 'integer', + 'description': 'Maximale Anzahl Datensaetze pro Seite.', + 'minimum': 1, + }, + 'offset': { + 'type': 'integer', + 'description': 'Startposition fuer Paging.', + 'minimum': 0, + }, + }, + 'required': ['model_name'], + 'additionalProperties': False, + }, + }, + } + ] + + def _safe_int(self, value, default=0, min_value=None, max_value=None): + try: + int_value = int(value) + except Exception: + int_value = default + if min_value is not None: + int_value = max(min_value, int_value) + if max_value is not None: + int_value = min(max_value, int_value) + return int_value + + def _last_user_query(self, cleaned_messages): + for item in reversed(cleaned_messages): + if item.get('role') == 'user' and item.get('content'): + return item['content'] + return '' + + def _row_to_field_value_string(self, row): + parts = [] + for key, value in (row or {}).items(): + if isinstance(value, (list, tuple)): + value_text = '|'.join([str(v) for v in value]) + elif value is None: + value_text = '' + else: + value_text = str(value) + parts.append('"%s":"%s"' % (str(key), value_text.replace('"', "'"))) + return ', '.join(parts) + + def _load_contract_rows(self, settings): + if not settings or not settings.ai_agent_contracts_access: + return [] + limit = self._safe_int(settings.ai_agent_contracts_limit or 0, default=0, min_value=0) + fields_to_read = [ + 'contract_auto_id', + 'contract_id', + 'contract_name', + 'contract_auto_name', + 'client_short_company', + 'client_short_vorname', + 'client_short_name', + 'contract_state', + 'project', + 'vertragssumme', + ] + return request.env['dss.contracts'].search_read( + [], + fields_to_read, + limit=limit if limit > 0 else None, + order='write_date desc', + ) + + def _load_project_rows(self, settings): + if not settings or not settings.ai_agent_projects_access: + return [] + limit = self._safe_int(settings.ai_agent_projects_limit or 0, default=0, min_value=0) + fields_to_read = [ + 'projectid', + 'projektname', + 'name', + 'standort_ort', + 'standort_plz', + 'standort_strasse', + 'maps_visitors', + 'aktstatus', + ] + return request.env['dss.projects'].search_read( + [], + fields_to_read, + limit=limit if limit > 0 else None, + order='write_date desc', + ) + + def _build_rag_documents(self, settings): + docs = [] + try: + contract_rows = self._load_contract_rows(settings) + self._log_params('rag.build_documents.contract_rows', { + 'count': len(contract_rows), + 'contracts_access': bool(settings and settings.ai_agent_contracts_access), + }) + for row in contract_rows: + contract_no = row.get('contract_auto_id') or row.get('contract_id') or '-' + contract_name = row.get('contract_name') or row.get('contract_auto_name') or '-' + customer = row.get('client_short_company') or ' '.join( + filter(None, [row.get('client_short_vorname'), row.get('client_short_name')]) + ).strip() or '-' + state = row.get('contract_state') + state_name = state[1] if isinstance(state, (list, tuple)) and len(state) > 1 else '-' + project = row.get('project') + project_name = project[1] if isinstance(project, (list, tuple)) and len(project) > 1 else '-' + amount = row.get('vertragssumme') + amount_text = str(amount) if amount not in (None, False) else '-' + full_field_value_string = self._row_to_field_value_string(row) + + docs.append({ + 'content': 'Vertrag %s | %s | Kunde: %s | Status: %s | Projekt: %s | Summe: %s | RAW : %s' % ( + contract_no, + contract_name, + customer, + state_name, + project_name, + amount_text, + full_field_value_string, + ), + 'metadata': { + 'type': 'contract', + 'id': contract_no, + 'name': contract_name, + 'fields': full_field_value_string, + }, + }) + + project_rows = self._load_project_rows(settings) + self._log_params('rag.build_documents.project_rows', { + 'count': len(project_rows), + 'projects_access': bool(settings and settings.ai_agent_projects_access), + }) + for row in project_rows: + project_no = row.get('projectid') or '-' + project_name = row.get('projektname') or row.get('name') or '-' + location_parts = [ + row.get('standort_strasse') or '', + row.get('standort_plz') or '', + row.get('standort_ort') or '', + ] + location = ' '.join([p for p in location_parts if p]).strip() or '-' + visitors = row.get('maps_visitors') if row.get('maps_visitors') not in (None, False) else '-' + status = row.get('aktstatus') + status_name = status[1] if isinstance(status, (list, tuple)) and len(status) > 1 else '-' + full_field_value_string = self._row_to_field_value_string(row) + docs.append({ + 'content': 'Projekt %s | %s | Ort: %s | Besucher: %s | Status: %s' % ( + project_no, + project_name, + location, + visitors, + status_name, + ), + 'metadata': { + 'type': 'project', + 'id': project_no, + 'name': project_name, + 'fields': full_field_value_string, + }, + }) + except AccessError: + return [], 'Hinweis: Fuer Teile des RAG-Kontexts fehlen Berechtigungen.' + except Exception as exc: + _logger.exception('Could not build RAG documents: %s', exc) + return [], 'Hinweis: RAG-Dokumente konnten nicht geladen werden.' + + self._log_params('rag.build_documents.result', {'doc_count': len(docs)}) + return docs, '' + + def _build_rag_context(self, settings, cleaned_messages): + if not settings or not settings.ai_agent_rag_enabled: + self._log_params('rag.build_context.skipped', { + 'enabled': bool(settings and settings.ai_agent_rag_enabled), + }) + return '', '' + + query = self._last_user_query(cleaned_messages) + if not query: + self._log_params('rag.build_context.no_query', {'cleaned_message_count': len(cleaned_messages or [])}) + return '', '' + + docs, warning = self._build_rag_documents(settings) + if not docs: + self._log_params('rag.build_context.no_docs', {'warning': warning}) + return '', warning + + try: + from langchain_community.retrievers import BM25Retriever + from langchain_core.documents import Document + except Exception: + return '', 'Hinweis: LangChain ist nicht installiert (benoetigt: langchain-community, langchain-core).' + + try: + lc_docs = [Document(page_content=d['content'], metadata=d['metadata']) for d in docs] + retriever = BM25Retriever.from_documents(lc_docs) + retriever.k = self._safe_int(settings.ai_agent_rag_top_k or 8, default=8, min_value=1, max_value=50) + self._log_params('rag.build_context.retrieve', { + 'query': query, + 'doc_count': len(lc_docs), + 'top_k': retriever.k, + }) + results = retriever.invoke(query) + except Exception as exc: + _logger.exception('RAG retrieval failed: %s', exc) + return '', 'Hinweis: RAG-Retrieval fehlgeschlagen (pruefe optionales Paket rank-bm25).' + + if not results: + self._log_params('rag.build_context.empty_results', {'query': query}) + return 'RAG-Kontext: Keine passenden Treffer gefunden.', warning + + lines = ['RAG-Treffer fuer Anfrage: %s' % query] + for idx, item in enumerate(results, start=1): + metadata = item.metadata or {} + doc_type = metadata.get('type', '-') + doc_id = metadata.get('id', '-') + lines.append('%s. [%s:%s] %s' % (idx, doc_type, doc_id, item.page_content)) + + rag_text = self._clip_text('\n'.join(lines), self._MAX_CONTRACT_CONTEXT_CHARS) + self._log_params('rag.build_context.result', { + 'query': query, + 'result_count': len(results), + 'rag_text_len': len(rag_text or ''), + }) + return rag_text, warning + + def _call_chat_api(self, base_url, api_key, model, payload_messages, temperature, timeout_seconds, tools=None): + self._log_params('api.call_chat_api.request', { + 'base_url': base_url, + 'model': model, + 'message_count': len(payload_messages or []), + 'temperature': temperature if temperature is not None else 0.3, + 'timeout_seconds': timeout_seconds, + 'tools_count': len(tools or []), + }) + payload = { + 'model': model, + 'messages': payload_messages, + 'temperature': temperature if temperature is not None else 0.3, + } + if tools: + payload['tools'] = tools + + response = requests.post( + '%s/chat/completions' % base_url, + headers={ + 'Authorization': 'Bearer %s' % api_key, + 'Content-Type': 'application/json', + }, + json=payload, + timeout=timeout_seconds, + ) + self._log_params('api.call_chat_api.response', { + 'status_code': response.status_code, + 'content_length': len(response.text or ''), + }) + return response + + def _build_contracts_context(self, settings): + if not settings or not settings.ai_agent_contracts_access: + return "" + + limit = settings.ai_agent_contracts_limit or 0 + try: + limit = int(limit) + except Exception: + limit = 0 + if limit < 0: + limit = 0 + + fields_to_read = [ + 'contract_auto_id', + 'contract_id', + 'contract_name', + 'contract_auto_name', + 'client_short_company', + 'client_short_vorname', + 'client_short_name', + 'contract_state', + 'project', + 'vertragssumme', + ] + + try: + total = request.env['dss.contracts'].search_count([]) + rows = request.env['dss.contracts'].search_read( + [], + fields_to_read, + limit=limit if limit > 0 else None, + order='write_date desc', + ) + except AccessError: + return "Hinweis: Der aktuelle Benutzer hat keinen Zugriff auf dss.contracts." + except Exception as exc: + _logger.exception('Could not build contracts context: %s', exc) + return "Hinweis: Vertragskontext konnte nicht geladen werden." + + if not rows: + return "Vertragskontext: Keine Vertraege gefunden." + + lines = [ + "Vertragskontext (dss.contracts): %s Datensaetze (geliefert: %s)" + % (total, len(rows)) + ] + for row in rows: + contract_no = row.get('contract_auto_id') or row.get('contract_id') or '-' + contract_name = row.get('contract_name') or row.get('contract_auto_name') or '-' + customer = row.get('client_short_company') or ' '.join( + filter(None, [row.get('client_short_vorname'), row.get('client_short_name')]) + ).strip() or '-' + + state = row.get('contract_state') + state_name = state[1] if isinstance(state, (list, tuple)) and len(state) > 1 else '-' + project = row.get('project') + project_name = project[1] if isinstance(project, (list, tuple)) and len(project) > 1 else '-' + amount = row.get('vertragssumme') + amount_text = str(amount) if amount not in (None, False) else '-' + + lines.append( + "- %s | %s | Kunde: %s | Status: %s | Projekt: %s | Summe: %s" + % (contract_no, contract_name, customer, state_name, project_name, amount_text) + ) + + return self._clip_text("\n".join(lines), self._MAX_CONTRACT_CONTEXT_CHARS) + + def _build_projects_context(self, settings): + if not settings or not settings.ai_agent_projects_access: + return "" + + limit = settings.ai_agent_projects_limit or 0 + try: + limit = int(limit) + except Exception: + limit = 0 + if limit < 0: + limit = 0 + + fields_to_read = [ + 'projectid', + 'projektname', + 'name', + 'standort_ort', + 'standort_plz', + 'standort_strasse', + 'maps_visitors', + 'aktstatus', + ] + + try: + total = request.env['dss.projects'].search_count([]) + rows = request.env['dss.projects'].search_read( + [], + fields_to_read, + limit=limit if limit > 0 else None, + order='write_date desc', + ) + except AccessError: + return "Hinweis: Der aktuelle Benutzer hat keinen Zugriff auf dss.projects." + except Exception as exc: + _logger.exception('Could not build projects context: %s', exc) + return "Hinweis: Projektkontext konnte nicht geladen werden." + + if not rows: + return "Projektkontext: Keine Projekte gefunden." + + lines = [ + "Projektkontext (dss.projects): %s Datensaetze (geliefert: %s)" + % (total, len(rows)) + ] + for row in rows: + project_no = row.get('projectid') or '-' + project_name = row.get('projektname') or row.get('name') or '-' + location_parts = [ + row.get('standort_strasse') or '', + row.get('standort_plz') or '', + row.get('standort_ort') or '', + ] + location = " ".join([p for p in location_parts if p]).strip() or '-' + visitors = row.get('maps_visitors') if row.get('maps_visitors') not in (None, False) else '-' + status = row.get('aktstatus') + status_name = status[1] if isinstance(status, (list, tuple)) and len(status) > 1 else '-' + lines.append( + "- %s | %s | Ort: %s | Besucher: %s | Status: %s" + % (project_no, project_name, location, visitors, status_name) + ) + + return self._clip_text("\n".join(lines), self._MAX_PROJECT_CONTEXT_CHARS) + + @http.route('/digitalsignage/ai_agent/chat', type='json', auth='user') + def ai_agent_chat(self, messages=None): + """Proxy chat requests from the backend UI to an OpenAI-compatible API.""" + settings = request.env['dss.settings'].sudo().search([], limit=1) + icp = request.env['ir.config_parameter'].sudo() + enabled = settings.ai_agent_enabled if settings else True + api_key = (settings.ai_agent_api_key if settings else False) or icp.get_param('digitalsignage_ai.api_key') or os.getenv('OPENAI_API_KEY', '') + base_url = ((settings.ai_agent_base_url if settings else False) or icp.get_param('digitalsignage_ai.base_url') or 'https://api.openai.com/v1').rstrip('/') + model = (settings.ai_agent_model if settings else False) or icp.get_param('digitalsignage_ai.model') or 'gpt-4o-mini' + system_prompt = ( + (settings.ai_agent_system_prompt if settings else False) + or icp.get_param('digitalsignage_ai.system_prompt') + or 'Du bist ein hilfreicher virtueller Mitarbeiter im Odoo Backend.' + ) + timeout_value = str( + (settings.ai_agent_timeout_seconds if settings and settings.ai_agent_timeout_seconds else False) + or icp.get_param('digitalsignage_ai.timeout_seconds') + or '60' + ) + temperature = ( + settings.ai_agent_temperature + if settings and settings.ai_agent_temperature is not False + else None + ) + max_context_messages = ( + settings.ai_agent_max_context_messages + if settings and settings.ai_agent_max_context_messages + else 20 + ) + + if not enabled: + return { + 'reply': 'Der AI Agent ist in den DigitalSignage Einstellungen deaktiviert.' + } + + if not api_key: + return { + 'reply': 'OpenAI API Key fehlt. Bitte setze digitalsignage_ai.api_key in den Systemparametern.' + } + + timeout_seconds = 60 + try: + timeout_seconds = max(5, int(timeout_value)) + except Exception: + timeout_seconds = 60 + + safe_messages = messages if isinstance(messages, list) else [] + self._log_params('chat.request.received', { + 'user_id': request.env.user.id, + 'enabled': bool(enabled), + 'base_url': base_url, + 'model': model, + 'temperature': temperature, + 'timeout_seconds': timeout_seconds, + 'max_context_messages': max_context_messages, + 'incoming_messages': len(safe_messages), + }) + cleaned_messages = [] + for item in safe_messages[-max_context_messages:]: + if not isinstance(item, dict): + continue + role = item.get('role') + content = item.get('content') + if role not in ('user', 'assistant'): + continue + if not isinstance(content, str): + continue + content = content.strip() + if not content: + continue + cleaned_messages.append({'role': role, 'content': content}) + + # Keep only the latest user prompt for model calls. + latest_user_prompt = self._last_user_query(cleaned_messages) + cleaned_messages = [{'role': 'user', 'content': latest_user_prompt}] if latest_user_prompt else [] + self._log_params('chat.request.cleaned', { + 'user_id': request.env.user.id, + 'cleaned_messages': len(cleaned_messages), + 'latest_user_prompt_len': len(latest_user_prompt or ''), + }) + + callback_prompt = (settings.ai_agent_callback_prompt if settings else '') or '' + payload_messages = [{ + 'role': 'system', + 'content': ( + system_prompt + + '\n\nArbeite tool-first: Hole Businessdaten nur bei Bedarf ueber Funktionen. ' + 'Sende keine grossen Datenmengen im Prompt. Nutze Paging (limit/offset).' + + ('\n\n' + callback_prompt if callback_prompt else '') + ), + }] + response_suffix = '' + + payload_messages += cleaned_messages + tools = self._build_openai_tools() + self._log_params('chat.request.payload', { + 'user_id': request.env.user.id, + 'payload_messages': len(payload_messages), + 'tools_count': len(tools), + }) + + try: + open_table = None + open_table_priority = -1 + response = self._call_chat_api( + base_url, + api_key, + model, + payload_messages, + temperature, + timeout_seconds, + tools=tools, + ) + + if response.status_code >= 400: + api_error_message = self._extract_api_error_message(response) + _logger.warning('AI Agent API error %s: %s', response.status_code, api_error_message) + + # Retry once with reduced payload when context becomes too large. + if response.status_code == 400 and payload_messages: + reduced_messages = [{'role': 'system', 'content': system_prompt}] + cleaned_messages[-6:] + retry_response = self._call_chat_api( + base_url, + api_key, + model, + reduced_messages, + temperature, + timeout_seconds, + tools=tools, + ) + if retry_response.status_code < 400: + retry_data = retry_response.json() or {} + retry_choices = retry_data.get('choices') or [] + if retry_choices and retry_choices[0].get('message') and retry_choices[0]['message'].get('content'): + return { + 'reply': "%s\n\n(Hinweis: Der Datenkontext war zu gross und wurde fuer diese Antwort reduziert.)" % retry_choices[0]['message']['content'].strip() + } + + if api_error_message: + return {'reply': 'AI-API Fehler (%s): %s' % (response.status_code, api_error_message)} + return {'reply': 'AI-API Fehler (%s). Bitte Konfiguration oder Zugang pruefen.' % response.status_code} + + data = response.json() or {} + choices = data.get('choices') or [] + if choices and choices[0].get('message'): + assistant_message = choices[0]['message'] + self._log_params('chat.response.first', { + 'user_id': request.env.user.id, + 'tool_calls_count': len(assistant_message.get('tool_calls') or []), + 'assistant_content_len': len((assistant_message.get('content') or '')), + }) + + current_message = assistant_message + tool_round = 0 + max_tool_rounds = 4 + + while True: + tool_calls = current_message.get('tool_calls') or [] + if not tool_calls: + break + + payload_messages.append({ + 'role': 'assistant', + 'content': current_message.get('content') or '', + 'tool_calls': tool_calls, + }) + + for tool_call in tool_calls: + function_info = tool_call.get('function') or {} + function_name = function_info.get('name') + raw_arguments = function_info.get('arguments') or '{}' + try: + arguments = json.loads(raw_arguments) + except Exception: + arguments = {} + + # Some providers return double-encoded JSON arguments. + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except Exception: + arguments = {} + + if not isinstance(arguments, dict): + self._log_params('chat.tool_call.arguments.invalid_type', { + 'user_id': request.env.user.id, + 'tool_call_id': tool_call.get('id'), + 'function_name': function_name, + 'arguments_type': type(arguments).__name__, + }) + arguments = {} + + self._log_params('chat.tool_call.dispatch', { + 'user_id': request.env.user.id, + 'tool_call_id': tool_call.get('id'), + 'function_name': function_name, + 'arguments': arguments, + }) + + if function_name == 'get_all_contracts': + try: + tool_result = self._tool_get_all_contracts(settings, arguments) + except AccessError: + tool_result = {'error': 'Kein Zugriff auf dss.contracts.'} + except Exception as exc: + _logger.exception('Tool get_all_contracts failed: %s', exc) + tool_result = {'error': 'Fehler beim Lesen von dss.contracts.'} + elif function_name == 'open_contract_form': + try: + tool_result = self._tool_open_contract_form(arguments) + except AccessError: + tool_result = {'error': 'Kein Zugriff auf dss.contracts.'} + except Exception as exc: + _logger.exception('Tool open_contract_form failed: %s', exc) + tool_result = {'error': 'Fehler beim Oeffnen des Vertragsformulars.'} + elif function_name == 'get_contract_by_number': + try: + tool_result = self._tool_get_contract_by_number(arguments) + except AccessError: + tool_result = {'error': 'Kein Zugriff auf dss.contracts.'} + except Exception as exc: + _logger.exception('Tool get_contract_by_number failed: %s', exc) + tool_result = {'error': 'Fehler beim Laden von Vertragsdetails.'} + elif function_name == 'get_projects': + try: + tool_result = self._tool_get_projects(arguments) + except AccessError: + tool_result = {'error': 'Kein Zugriff auf dss.projects.'} + except Exception as exc: + _logger.exception('Tool get_projects failed: %s', exc) + tool_result = {'error': 'Fehler beim Lesen von dss.projects.'} + elif function_name == 'get_system_datetime': + try: + tool_result = self._tool_get_system_datetime(arguments) + except Exception as exc: + _logger.exception('Tool get_system_datetime failed: %s', exc) + tool_result = {'error': 'Fehler beim Ermitteln von Datum/Uhrzeit.'} + elif function_name == 'create_model_record': + try: + tool_result = self._tool_create_model_record(settings, arguments) + except AccessError: + tool_result = {'error': 'Kein Zugriff auf das angefragte Modell zum Anlegen.'} + except Exception as exc: + _logger.exception('Tool create_model_record failed: %s', exc) + tool_result = {'error': 'Fehler beim Anlegen des Datensatzes.'} + elif function_name == 'update_model_record': + try: + tool_result = self._tool_update_model_record(settings, arguments) + except AccessError: + tool_result = {'error': 'Kein Zugriff auf das angefragte Modell zum Aendern.'} + except Exception as exc: + _logger.exception('Tool update_model_record failed: %s', exc) + tool_result = {'error': 'Fehler beim Aendern des Datensatzes.'} + elif function_name == 'search_all_records': + try: + tool_result = self._tool_search_all_records(settings, arguments) + except AccessError: + tool_result = {'error': 'Kein Zugriff auf das angefragte Modell.'} + except Exception as exc: + _logger.exception('Tool search_all_records failed: %s', exc) + tool_result = {'error': 'Fehler bei search_all_records.'} + elif function_name == 'get_model_records': + try: + tool_result = self._tool_get_model_records(settings, arguments) + except AccessError: + tool_result = {'error': 'Kein Zugriff auf das angefragte Modell.'} + except Exception as exc: + _logger.exception('Tool get_model_records failed: %s', exc) + tool_result = {'error': 'Fehler beim generischen Modellabruf.'} + else: + tool_result = {'error': 'Unbekannte Funktion: %s' % function_name} + + payload_messages.append({ + 'role': 'tool', + 'tool_call_id': tool_call.get('id'), + 'content': json.dumps(tool_result, ensure_ascii=False), + }) + + self._log_params('chat.tool_call.result', { + 'user_id': request.env.user.id, + 'tool_call_id': tool_call.get('id'), + 'function_name': function_name, + 'result_keys': sorted(list(tool_result.keys())) if isinstance(tool_result, dict) else [], + }) + + if isinstance(tool_result, dict): + explicit_open_table = tool_result.get('open_table') + explicit_open_table_applied = False + if isinstance(explicit_open_table, dict): + candidate_priority = self._open_table_priority(function_name) + if candidate_priority >= open_table_priority: + open_table = explicit_open_table + open_table_priority = candidate_priority + explicit_open_table_applied = True + + result_model = tool_result.get('model') + result_domain = tool_result.get('domain') + result_returned = self._safe_int(tool_result.get('returned', 0), default=0, min_value=0) + result_total = self._safe_int(tool_result.get('total', 0), default=0, min_value=0) + result_record_ids = tool_result.get('record_ids') or [] + if (not explicit_open_table_applied) and result_model and isinstance(result_domain, list) and max(result_returned, result_total) > 0: + candidate_priority = self._open_table_priority(function_name) + if candidate_priority >= open_table_priority: + open_table = self._build_open_table_payload( + result_model, + result_domain, + returned=result_returned, + total=result_total, + record_ids=result_record_ids, + ) + open_table_priority = candidate_priority + + tool_round += 1 + if tool_round >= max_tool_rounds: + final_reply = 'Zu viele aufeinanderfolgende Tool-Aufrufe. Bitte die Anfrage vereinfachen.' + self._log_params('chat.tool_call.max_rounds', { + 'user_id': request.env.user.id, + 'tool_rounds': tool_round, + }) + self._save_exchange(cleaned_messages, final_reply) + if open_table: + return {'reply': final_reply, 'open_table': open_table} + return {'reply': final_reply} + + followup_response = self._call_chat_api( + base_url, + api_key, + model, + payload_messages, + temperature, + timeout_seconds, + tools=tools, + ) + if followup_response.status_code >= 400: + api_error_message = self._extract_api_error_message(followup_response) + self._log_params('chat.followup.error', { + 'user_id': request.env.user.id, + 'status_code': followup_response.status_code, + 'api_error_message': api_error_message, + }) + return {'reply': 'AI-API Fehler (%s): %s' % (followup_response.status_code, api_error_message)} + + followup_data = followup_response.json() or {} + followup_choices = followup_data.get('choices') or [] + if not followup_choices or not followup_choices[0].get('message'): + final_reply = 'Die AI hat keine verwertbare Antwort geliefert.' + self._log_params('chat.followup.empty_reply', { + 'user_id': request.env.user.id, + 'tool_rounds': tool_round + 1, + }) + self._save_exchange(cleaned_messages, final_reply) + if open_table: + return {'reply': final_reply, 'open_table': open_table} + return {'reply': final_reply} + + current_message = followup_choices[0]['message'] + + final_reply = (current_message.get('content') or '').strip() + response_suffix + if not final_reply: + final_reply = 'Die AI hat keine verwertbare Antwort geliefert.' + self._log_params('chat.followup.success', { + 'user_id': request.env.user.id, + 'reply_len': len(final_reply or ''), + 'tool_rounds': tool_round, + }) + self._save_exchange(cleaned_messages, final_reply) + if open_table: + return {'reply': final_reply, 'open_table': open_table} + return {'reply': final_reply} + + final_reply = 'Die AI hat keine verwertbare Antwort geliefert.' + self._log_params('chat.empty_reply', { + 'user_id': request.env.user.id, + 'choices_count': len(choices), + }) + self._save_exchange(cleaned_messages, final_reply) + return {'reply': final_reply} + except requests.exceptions.RequestException as exc: + _logger.exception('AI Agent request failed: %s', exc) + final_reply = 'Verbindung zur AI fehlgeschlagen. Bitte spaeter erneut versuchen.' + self._save_exchange(cleaned_messages, final_reply) + return {'reply': final_reply} + except Exception as exc: + _logger.exception('Unexpected AI Agent error: %s', exc) + final_reply = 'Unerwarteter Fehler im AI-Agenten.' + self._save_exchange(cleaned_messages, final_reply) + return {'reply': final_reply} diff --git a/controllers/dss_form_input_controller.py b/controllers/dss_form_input_controller.py old mode 100644 new mode 100755 index 79a4b80..8153821 --- a/controllers/dss_form_input_controller.py +++ b/controllers/dss_form_input_controller.py @@ -61,6 +61,14 @@ class CustomerPortal(portal.CustomerPortal): contract_name = str(tempid)+' - Vertrag ' + str(project.lastkdnr).zfill(2) contract_id = str(project.projectid) + str(project.lastrealkdnr).zfill(2) grundsystem_webinput_template=project.web_input_template + felder = request.env['dss.advertisefields'].search(['&','|',('contract','=',False),('isblocked','=',False),('project','=',project.id)]) + comptyps = request.env['dss.company.type'].search([]) + fo ="" + for feld in felder: + fo = fo+"" + ctn = "" + for comptyp in comptyps: + ctn = ctn+"" if not grundsystem_webinput_template: grundsystem_webinput_template = project.grundsystem_webinput_template if not grundsystem_webinput_template: @@ -68,8 +76,27 @@ class CustomerPortal(portal.CustomerPortal): else: new_contract = request.env['dss.contracts'].create({'project_id':project.projectid,'project':project.id,'client_id':str(project.lastkdnr).zfill(2),'contract_auto_id':str(tempid),'contract_auto_name':str(tempname),'contract_state':39,'contract_name':contract_name,'web_contract_input_notfinished':True,'contract_creator':int(user_id.id)}) contract_data_id=new_contract.id + formdata = { + 'websideusername':name, + 'user_id':str(user_id.id), + 'projectid':str(project.projectid), + 'project':str(projekt_id), + 'contract_id':str(contract_id), + 'kd_id':str(str(project.lastrealkdnr).zfill(2)), + 'web_contract_paysum_gfx':'249', + 'contract_data_id':str(contract_data_id), + 'company_type_options':str(ctn), + 'client_short_company_type_options':str(ctn), + 'feldoptions':fo, + 'lastschrift': 'off', + 'satzmail': 'off', + 'rgmail': 'off', + 'kamail': 'off', + 'satzgiven': 'off', + 'showimage' : project.grundsystem_webinput_showimage_url + } _logger.info("inside start: "+str(name)+'--'+str(projekt_id)+'--'+str(grundsystem_webinput_template)) - return request.render("website."+grundsystem_webinput_template,{'websideusername':name,'user_id':str(user_id.id),'projectid':str(project.projectid),'project':str(projekt_id),'contract_id':str(contract_id),'kd_id':str(str(project.lastrealkdnr).zfill(2)),'web_contract_paysum_gfx':'249','contract_data_id':str(contract_data_id)}) + return request.render("website."+grundsystem_webinput_template,formdata) @route(['/eingabe/continue'],type='http', auth='user', website=True, csrf=False) def inputcontinue(self, **kwargs): @@ -81,63 +108,116 @@ class CustomerPortal(portal.CustomerPortal): grundsystem_webinput_template=project.web_input_template if not grundsystem_webinput_template: grundsystem_webinput_template = project.grundsystem_webinput_template - _logger.info("inside continue: "+str(contract.contract_auto_name)+'--'+str(project.id)+'--'+str(grundsystem_webinput_template)) + _logger.info("inside continue: "+str(contract.contract_auto_name)+'--'+str(project.id)+'--'+str(grundsystem_webinput_template)+'--'+str(str(contract.client_short_company_type.uuid))) felder = request.env['dss.advertisefields'].search(['&','|',('contract','=',False),('isblocked','=',False),('project','=',project.id)]) + comptyps = request.env['dss.company.type'].search([]) fo ="" sfeld ="" + ctn = "" for feld in felder: fo = fo+"" for feld in contract.werbe_feld_selected: sfeld = sfeld+feld.auto_feldname+", " + for comptyp in comptyps: + ctn = ctn+"" evtlcontract_id = str(contract.project.projectid) + str(contract.project.lastrealkdnr).zfill(2) evtlcontract_name = str(evtlcontract_id)+'_'+str(contract.client_short_company) if contract.client_short_company else str(contract.client_short_name)+' '+str(contract.client_short_vorname) - olddata = { + olddata = { 'user_id':str(user_id), 'projectid':str(project.projectid), 'project':str(project.id), + 'company_type_options':str(ctn), 'contract_id':str(evtlcontract_id), - 'client_short_company':str(contract.client_short_company), - 'client_short_strasse':str(contract.client_short_strasse), - 'client_short_plz':str(contract.client_short_plz), - 'client_short_telefon':str(contract.client_short_telefon), - 'client_short_email':str(contract.client_short_email), - 'client_short_rgemail':str(contract.client_short_rgemail), - 'client_short_ort':str(contract.client_short_ort), - 'client_short_mobil':str(contract.client_short_mobil), - 'client_short_website':str(contract.client_short_website), - 'client_short_vorname':str(contract.client_short_vorname), - 'client_short_name':str(contract.client_short_name), - 'web_contract_paysum_lz':contract.web_contract_paysum_lz, - 'web_contract_paysum_all':contract.web_contract_paysum_all, - 'web_contract_paysum_gfx':contract.web_contract_paysum_gfx, + 'client_short_company':str(contract.client_short_company) if contract.client_short_company else '', + 'client_short_company_type':str(contract.client_short_company_type.uuid), + 'client_short_company_type_options':str(ctn) , + 'client_short_strasse':str(contract.client_short_strasse) if contract.client_short_strasse else '', + 'client_short_plz':str(contract.client_short_plz) if contract.client_short_plz else '', + 'client_short_telefon':str(contract.client_short_telefon) if contract.client_short_telefon else '', + 'client_short_email':str(contract.client_short_email) if contract.client_short_email else '', + 'client_short_rgemail':str(contract.client_short_rgemail) if contract.client_short_rgemail else '', + 'client_short_ort':str(contract.client_short_ort) if contract.client_short_ort else '', + 'client_short_mobil':str(contract.client_short_mobil) if contract.client_short_mobil else '', + 'client_short_website':str(contract.client_short_website) if contract.client_short_website else '', + 'client_short_vorname':str(contract.client_short_vorname) if contract.client_short_vorname else '', + 'client_short_name':str(contract.client_short_name) if contract.client_short_name else '', + 'web_contract_paysum_lz':contract.web_contract_paysum_lz if contract.web_contract_paysum_lz else '0', + 'web_contract_paysum_all':contract.web_contract_paysum_all if contract.web_contract_paysum_all else '0', + 'web_contract_paysum_gfx':contract.web_contract_paysum_gfx if contract.web_contract_paysum_gfx else '0', + 'web_contract_full_duration':contract.main_runtime if contract.main_runtime else '0', + 'web_contract_spot_count':contract.split_runtime_count if contract.split_runtime_count else '0', + 'web_contract_spot_duration':contract.split_runtime_time if contract.split_runtime_time else '0', + 'web_tv_reach_PLZ':contract.tv_reach_PLZ if contract.tv_reach_PLZ else '', + 'web_ads_last_date_start_planed':contract.web_contract_start_planed if contract.web_contract_start_planed else '', + 'web_ads_last_date_remove_planed':contract.web_contract_remove_planed if contract.web_contract_remove_planed else '', + 'web_bande_spieltage':contract.web_bande_spieltage if contract.web_bande_spieltage else '', 'contract_data_id':str(contract.id), + 'runtime_m':contract.runtime_m if contract.runtime_m else '60', + 'lastschrift': 'on' if contract.web_contract_is_lastschrift else 'off', + 'zahlsonst': contract.web_constract_zahlstonst if contract.web_constract_zahlstonst else '', 'feldoptions':fo, - 'feldselected':sfeld,'kd_id':evtlcontract_name, - 'Datum': contract.web_contract_sign_datum, - 'Ort': contract.web_contract_sign_ort, - 'NameVorname': contract.web_contract_sign_name, - 'bic': contract.client_short_BIC, - 'iban': contract.client_short_IBAN, - 'kapay': contract.web_contract_kapayment_info, - 'alpay': contract.web_contract_alpayment_info, - 'sonder_0': contract.web_contract_sonder_0, - 'sonder_1': contract.web_contract_sonder_1, - 'sonder_2': contract.web_contract_sonder_2, + 'feldselected':sfeld if sfeld else '', + 'kd_id':evtlcontract_name, + 'ort': contract.web_contract_sign_ort if contract.web_contract_sign_ort else '', + 'namevorname': contract.web_contract_sign_name if contract.web_contract_sign_name else '', + 'bic': contract.client_short_BIC if contract.client_short_BIC else '', + 'iban': contract.client_short_IBAN if contract.client_short_IBAN else '', + 'kapay': contract.web_contract_kapayment_info if contract.web_contract_kapayment_info else '', + 'alpay': contract.web_contract_alpayment_info if contract.web_contract_alpayment_info else '', + 'sonder_0': contract.web_contract_sonder_0 if contract.web_contract_sonder_0 else '', + 'sonder_1': contract.web_contract_sonder_1 if contract.web_contract_sonder_1 else '', + 'sonder_2': contract.web_contract_sonder_2 if contract.web_contract_sonder_2 else '', + 'satzmail': 'on' if contract.web_contract_satzmail else 'off', + 'rgmail': 'on' if contract.web_contract_rgmail else 'off' , + 'kamail': 'on' if contract.web_contract_kamail else 'off', + 'satzgiven': 'on' if contract.web_contract_satzgiven else 'off', + 'web_ads_count_per_year2': contract.ads_count_perYear2 if contract.ads_count_perYear2 else '0', + 'contract_auto_extend' : 'on' if contract.contract_auto_extend else 'off', + 'contract_auto_extend_time': contract.contract_auto_extend_time if contract.contract_auto_extend_time else '0', + 'web_contract_cancel_date': contract.contract_cancel_date.strftime("%d.%m.%Y") if contract.contract_cancel_date else '', + 'edited': "1" # damit im Template erkannt wird, dass es sich um eine Fortsetzung handelt und nicht um einen neuen Vertrag } return request.render("website."+grundsystem_webinput_template, olddata) + @route(['/eingabe/upload'],type='http', auth='user', website=True, csrf=False) + def inputcontinueupload(self, **kwargs): + _logger.info("inside upload continue : " + str(request.params)+' --'+str(kwargs)+' --'+str(request.httprequest.data)) + user_id = request.env.user + contract = request.env['dss.contracts'].search([('uuid','=',kwargs.get('uuid'))],limit=1) + contract_id = kwargs.get('contract_id') + contract_data_id = contract.id + kd_id = kwargs.get('kd_id') + #contract = self.setcontract(request.env['dss.contracts'].search([('id','=',contract_data_id)],limit=1),kwargs) + uploads=request.env['dss.mediatypes'].search([('weboptionupload_show','=',True)]) + return request.render("website.eingabe-phase3-upload",{'user_id':str(user_id),'contract_id':str(contract_id),'kd_id':str(kd_id),'contract_data_id':str(contract_data_id),'uploadfiles':uploads}) + @route(['/eingabe/finish'],type='http', auth='user', website=True, csrf=False) def finish_contract(self, **kwargs): _logger.info("inside: start changeScreenTemplateView" + str(request.params)+' --'+str(kwargs)+' --'+str(request.httprequest.data)) uuid = kwargs.get('uuid') contract = request.env['dss.contracts'].search([('uuid','=',uuid)],limit=1) - _logger.info("inside finish_contract: "+str(contract.id)+'--'+str(contract.contract_auto_id)+'--'+str(uuid)) + _logger.info("inside finish_contract: "+str(contract.id)+'--'+str(contract.contract_auto_id)+'--'+str(uuid)) + if not contract.web_contract_input_finished: + if contract.project.lostkdnr: + if len(contract.project.lostkdnr)>0: + nextnr = contract.project.lostkdnr.split(',')[0] + contract.project.lostkdnr = ','.join(contract.project.lostkdnr.split(',')[1:]) + else: + contract.project.lastrealkdnr += 1 + nextnr = str(contract.project.lastrealkdnr).zfill(2) + else: + contract.project.lastrealkdnr += 1 + nextnr = str(contract.project.lastrealkdnr).zfill(2) + contract_id = str(contract.project.projectid) + str(nextnr) + contract.contract_id = str(nextnr) + contract.client_id = str(nextnr) + contract.contract_auto_id = contract_id + contract.contract_name = str(contract.client_short_company) if contract.client_short_company else str(contract.client_short_name)+' '+str(contract.client_short_vorname) + contract.contract_auto_name=str(contract_id)+'_'+str(contract.client_short_company) if contract.client_short_company else str(contract.client_short_name)+' '+str(contract.client_short_vorname) + dsscontracts.tokampagne(contract) + contract.web_contract_input_finished = True + contract.web_contract_rejected = False contract.web_contract_input_notfinished=False - contract_id = str(contract.project.projectid) + str(contract.project.lastrealkdnr).zfill(2) - contract.contract_auto_id = contract_id - contract.contract_name = str(contract_id)+' - '+str(contract.client_short_company) if contract.client_short_company else str(contract.client_short_name)+' '+str(contract.client_short_vorname) - contract.contract_auto_name=str(contract_id)+'_'+str(contract.client_short_company) if contract.client_short_company else str(contract.client_short_name)+' '+str(contract.client_short_vorname) - dsscontracts.tokampagne(contract) return request.render("website.eingabe-finished",{'contract':contract,'contract_auto_id':contract.contract_auto_id}) @route(['/eingabe/cancel'],type='http', auth='user', website=True, csrf=False) @@ -151,7 +231,8 @@ class CustomerPortal(portal.CustomerPortal): def setcontract(self,contract,kwargs): projekt_id = kwargs.get('project') - contract.client_short_company = kwargs.get('client_short_company') if kwargs.get('client_short_company') else '' + contract.client_short_company = kwargs.get('client_short_company') if kwargs.get('client_short_company') else '' + contract.client_short_company_type = request.env['dss.company.type'].search([('uuid','=',kwargs.get('client_short_company_type'))],limit=1) if kwargs.get('client_short_company_type') else '' contract.client_short_vorname = kwargs.get('client_short_vorname') if kwargs.get('client_short_vorname') else '' contract.client_short_name = kwargs.get('client_short_name') if kwargs.get('client_short_name') else '' contract.client_short_telefon = kwargs.get('client_short_telefon') if kwargs.get('client_short_telefon') else '' @@ -162,24 +243,62 @@ class CustomerPortal(portal.CustomerPortal): contract.client_short_ort = kwargs.get('client_short_ort') if kwargs.get('client_short_ort') else '' contract.client_short_mobil = kwargs.get('client_short_mobil') if kwargs.get('client_short_mobil') else '' contract.client_short_website = kwargs.get('client_short_website') if kwargs.get('client_short_website') else '' - contract.runtimesystem = "M" - contract.runtime_m = kwargs.get('contract_time') if kwargs.get('contract_time') else '64' + contract.runtimesystem = "M" + + try: + laufzeit = int(kwargs.get('contract_time')) + except: + laufzeit = 60 + contract.runtime_m = laufzeit if laufzeit > 0 else kwargs.get('runtime_m') if laufzeit == -1 else 60 contract.web_contract_paysum_lz = kwargs.get('web_contract_paysum_lz') if kwargs.get('web_contract_paysum_lz') else 0.0 contract.web_contract_paysum_gfx = kwargs.get('web_contract_paysum_gfx') if kwargs.get('web_contract_paysum_gfx') else 0.0 contract.web_contract_paysum_all = kwargs.get('web_contract_paysum_all') if kwargs.get('web_contract_paysum_all') else 0.0 contract.web_contract_sign_datum = kwargs.get('Datum') if kwargs.get('Datum') else '' contract.web_contract_sign_ort = kwargs.get('Ort') if kwargs.get('Ort') else '' - contract.contract_date = datetime.datetime.strptime(contract.web_contract_sign_datum, "%d.%m.%Y").date() contract.web_contract_sign_name = kwargs.get('NameVorname') if kwargs.get('NameVorname') else '' contract.client_short_BIC = kwargs.get('bic') if kwargs.get('bic') else '' contract.client_short_IBAN = kwargs.get('iban') if kwargs.get('iban') else '' - contract.web_contract_is_lastschrift = kwargs.get('lastschrift') == 'on' if kwargs.get('lastschrift') else False + contract.web_contract_is_lastschrift = True if kwargs.get('lastschrift') else False + contract.web_constract_zahlstonst = kwargs.get('zahlsonst') if kwargs.get('zahlsonst') else '' contract.work_state_info = kwargs.get('sonder_2') if kwargs.get('sonder_2') else '' contract.web_contract_kapayment_info = kwargs.get('kapay') if kwargs.get('kapay') else '' contract.web_contract_alpayment_info = kwargs.get('alpay') if kwargs.get('alpay') else '' + contract.web_contract_satzmail = True if kwargs.get('satzmail') else False + contract.web_contract_satzgiven = True if kwargs.get('satzgiven') else False + contract.web_contract_rgmail = True if kwargs.get('rgmail') else False + contract.web_contract_kamail = True if kwargs.get('kamail') else False contract.web_contract_sonder_0 = kwargs.get('sonder_0') if kwargs.get('sonder_0') else '' contract.web_contract_sonder_1 = kwargs.get('sonder_1') if kwargs.get('sonder_1') else '' contract.web_contract_sonder_2 = kwargs.get('sonder_2') if kwargs.get('sonder_2') else '' + contract.main_runtime = kwargs.get('web_contract_full_duration') if kwargs.get('web_contract_full_duration') else '0' + contract.split_runtime_count = kwargs.get('web_contract_spot_count') if kwargs.get('web_contract_spot_count') else '0' + contract.split_runtime_time = kwargs.get('web_contract_spot_duration') if kwargs.get('web_contract_spot_duration') else '0' + contract.ads_count_perYear2 = kwargs.get('web_ads_count_per_year2') if kwargs.get('web_ads_count_per_year2') else '0' + contract.tv_reach_PLZ = kwargs.get('web_tv_reach_PLZ') if kwargs.get('web_tv_reach_PLZ') else '' + contract.web_contract_start_planed = kwargs.get('web_ads_last_date_start_planed') if kwargs.get('web_ads_last_date_start_planed') else '' + contract.web_contract_remove_planed = kwargs.get('web_ads_last_date_remove_planed') if kwargs.get('web_ads_last_date_remove_planed') else '' + contract.web_bande_spieltage = kwargs.get('web_bande_spieltage') if kwargs.get('web_bande_spieltage') else '' + contract.contract_auto_extend = True if kwargs.get('contract_auto_extend') else False + contract.contract_auto_extend_time = kwargs.get('contract_auto_extend_time') if kwargs.get('contract_auto_extend_time') else '0' + cancel_date = kwargs.get('web_contract_cancel_date') if kwargs.get('web_contract_cancel_date') else '' + if cancel_date: + try: + datetime.datetime.strptime(cancel_date, "%Y-%m-%d") + contract.contract_cancel_date = cancel_date + except ValueError: + try: + converted_date = datetime.datetime.strptime(cancel_date, "%d.%m.%Y").strftime("%Y-%m-%d") + contract.contract_cancel_date = converted_date + except ValueError: + contract.contract_cancel_date = '' + else: + contract.contract_cancel_date = '' + + try: + contract.contract_date = datetime.datetime.strptime(contract.web_contract_sign_datum, "%d.%m.%Y").date() + except: + # ignore error and do not set date if format is wrong + pass gesamts = kwargs.get('sonder_0') if kwargs.get('sonder_0') else '' gesamts = gesamts+' //// '+kwargs.get('sonder_1') if kwargs.get('sonder_1') else '' contract.remark = gesamts @@ -189,11 +308,15 @@ class CustomerPortal(portal.CustomerPortal): for wfa in wfarray: if wfa: if '_' in wfa: - wfa = wfa.split('_')[1] + wfa = wfa.split('_')[1] wfaid = request.env['dss.advertisefields'].search([('feldname','=',wfa.strip()),('project','=',contract.project.id)],limit=1) _logger.info("inside update : "+str(projekt_id)+'/'+str(contract.project.id)+'--'+str(wfields)+'--'+str(wfa.strip()+'--'+str(wfaid))) if wfaid: contract.werbe_feld_selected = [(4, wfaid.id)] + else: + wfaid = request.env['dss.advertisefields'].create({'feldname':wfa.strip(),'project':contract.project.id,'auto_feldname':str(str(contract.project.projectid)+'_'+wfa.strip())}) + if wfaid: + contract.werbe_feld_selected = [(4, wfaid.id)] return contract @route(['/eingabe/submit_lcdtouch'],type='http', auth='user', website=True, csrf=False) @@ -206,11 +329,34 @@ class CustomerPortal(portal.CustomerPortal): contract_id = kwargs.get('contract_id') kd_id = kwargs.get('kd_id') contract_data_id = kwargs.get('contract_data_id') - _logger.info("inside: "+str(name)+'--'+str(projekt_id)+'--'+str(kd_id)+'--'+str(contract_id)+'--'+str(contract_data_id)+'--'+str(projectid)+'--'+str(kwargs.get('kamail'))) + _logger.info("inside: "+str(name)+'--'+str(projekt_id)+'--'+str(kd_id)+'--'+str(contract_id)+'--'+str(contract_data_id)+'--'+str(projectid)+'--'+str(kwargs.get('kamail'))) contract = self.setcontract(request.env['dss.contracts'].search([('id','=',contract_data_id)],limit=1),kwargs) - return request.render("website.eingabe-phase3-upload",{'websideusername':name,'user_id':str(user_id),'projectid':str(projectid),'project':str(projekt_id),'contract_id':str(contract_id),'kd_id':str(kd_id),'contract_data_id':str(contract_data_id)}) + uploads=request.env['dss.mediatypes'].search([('weboptionupload_show','=',True)]) + return request.render("website.eingabe-phase3-upload",{'websideusername':name,'user_id':str(user_id),'projectid':str(projectid),'project':str(projekt_id),'contract_id':str(contract_id),'kd_id':str(kd_id),'contract_data_id':str(contract_data_id),'uploadfiles':uploads}) - @route(['/eingabe/submit_kfz'],type='http', auth='user', website=True, csrf=False) + @route(['/eingabe/submit_save'],type='json', auth='user', methods=['POST'], csrf=True) + def submit_save(self, **kwargs): + _logger.info("inside: Save Received Data " + str(request.params)+' --'+str(kwargs)+' --'+str(request.httprequest.data)) + kwargs = json.loads(request.httprequest.data.decode('utf-8')) + _logger.info("inside: Save Received Data " + str(request.params)+' --'+str(kwargs)+' --'+str(request.httprequest.data)) + name = kwargs.get('websideusername') + projekt_id = kwargs.get('project') + projectid = kwargs.get('projectid') + user_id = kwargs.get('user_id') + contract_id = kwargs.get('contract_id') + contract_data_id = kwargs.get('contract_data_id') + kd_id = kwargs.get('kd_id') + contract = self.setcontract(request.env['dss.contracts'].search([('id','=',contract_data_id)],limit=1),kwargs) + _logger.info("inside: Save Received Data "+str(name)+'--'+str(projekt_id)+'--'+str(kd_id)+'--'+str(contract_id)+'--'+str(contract_data_id)) + output = { + 'results':{ + 'code':200, + 'message':'OK' + } + } + return json.dumps(output) + + @route(['/eingabe/submit_all'],type='http', auth='user', website=True, csrf=False) def submit_kfz(self, **kwargs): _logger.info("inside: start changeScreenTemplateView" + str(request.params)+' --'+str(kwargs)+' --'+str(request.httprequest.data)) name = kwargs.get('websideusername') @@ -222,7 +368,8 @@ class CustomerPortal(portal.CustomerPortal): kd_id = kwargs.get('kd_id') contract = self.setcontract(request.env['dss.contracts'].search([('id','=',contract_data_id)],limit=1),kwargs) _logger.info("inside: "+str(name)+'--'+str(projekt_id)+'--'+str(kd_id)+'--'+str(contract_id)+'--'+str(contract_data_id)) - return request.render("website.eingabe-phase3-upload",{'websideusername':name,'user_id':str(user_id),'projectid':str(projectid),'project':str(projekt_id),'contract_id':str(contract_id),'kd_id':str(kd_id),'contract_data_id':str(contract_data_id)}) + uploads=request.env['dss.mediatypes'].search([('weboptionupload_show','=',True)]) + return request.render("website.eingabe-phase3-upload",{'websideusername':name,'user_id':str(user_id),'projectid':str(projectid),'project':str(projekt_id),'contract_id':str(contract_id),'kd_id':str(kd_id),'contract_data_id':str(contract_data_id),'uploadfiles':uploads}) @route(['/eingabe/submit_stream'],type='http', auth='user', website=True, csrf=False) def submit_stream(self, **kwargs): @@ -233,6 +380,8 @@ class CustomerPortal(portal.CustomerPortal): user_id = kwargs.get('user_id') contract_id = kwargs.get('contract_id') kd_id = kwargs.get('kd_id') + a=2 + contract_data_id = kwargs.get('contract_data_id') contract = self.setcontract(request.env['dss.contracts'].search([('id','=',contract_data_id)],limit=1),kwargs) _logger.info("inside: "+str(name)+'--'+str(projekt_id)+'--'+str(kd_id)+'--'+str(contract_id)+'--'+str(contract_data_id)) @@ -247,8 +396,15 @@ class CustomerPortal(portal.CustomerPortal): user_id = kwargs.get('user_id') contract_id = kwargs.get('contract_id') kd_id = kwargs.get('kd_id') + filetype = kwargs.get('filetype') + file = kwargs.get('filename') + b64file = base64.b64encode(file.read()) contract_data_id = kwargs.get('contract_data_id') - _logger.info("inside: "+str(name)+'--'+str(projekt_id)+'--'+str(kd_id)+'--'+str(contract_id)+'--'+str(contract_data_id)) + contract=request.env['dss.contracts'].search([('id','=',contract_data_id)],limit=1) + if filetype=="1": + _logger.info("inside: set Contract "+str(b64file)) + contract.scan_vertrag=b64file + _logger.info("inside: "+str(name)+'--'+str(projekt_id)+'--'+str(kd_id)+'--'+str(contract_id)+'--'+str(contract_data_id)+'--'+str(file)+'---'+str(b64file)) return request.render("website.eingabe-phase4-finish",{'websideusername':name,'user_id':str(user_id),'projectid':str(projectid),'project':str(projekt_id),'contract_id':str(contract_id),'kd_id':str(kd_id),'contract_data_id':str(contract_data_id)}) @route(['/eingabe/submit_finish'],type='http', auth='user', website=True, csrf=False) @@ -264,7 +420,7 @@ class CustomerPortal(portal.CustomerPortal): submit_finish = kwargs.get('submit') _logger.info("inside finish : "+str(name)+'--'+str(projekt_id)+'--'+str(kd_id)+'--'+str(contract_id)+'--'+str(contract_data_id)+'--'+str(submit_finish)) if submit_finish == 'Eingabe beenden': - return request.render("website.eingabe-phase1") + return self.ad_portal_my_documents(self, **kwargs) if submit_finish == 'Neuen Vertrag im gleichen Projekt': return self.changeScreenView(**kwargs) if submit_finish == 'Neuen Vertrag in anderen Projekt': diff --git a/controllers/dss_webhook_controller.py b/controllers/dss_webhook_controller.py old mode 100644 new mode 100755 diff --git a/models/__init__.py b/models/__init__.py index e13d416..1ed157a 100755 --- a/models/__init__.py +++ b/models/__init__.py @@ -5,6 +5,7 @@ from . import dss_settings from . import dss_trigger from . import dss_ads from . import dss_contract +from . import dss_contracts_move_field_wizard from . import dss_contract_uservar from . import dss_projects from . import dss_geraetetypen @@ -15,6 +16,9 @@ from . import dss_eventdays from . import dss_display_templates from . import dss_advertisefields_templates from . import dss_advertisefields +from . import dss_advertisefields_create_from_template_wizard +from . import dss_advertisefields_create_from_project_wizard +from . import dss_advertisefields_save_as_template_wizard from . import dss_trigger from . import dss_triggerexecute from . import dss_triggervalues @@ -23,6 +27,7 @@ from . import dss from . import dss_maintains from . import dss_provisionstypen from . import dss_provision +from . import dss_company_type from . import company from . import dss_invoices from . import dss_importinvoicelist @@ -35,6 +40,10 @@ from . import dss_crm_extended from . import dss_kileads_requests from . import dss_kileads from . import dss_res_users +from . import dss_portal_user_profile from . import dss_web_contracts_stream from . import dss_settings_variables +from . import dss_csvs +from . import dss_ai_chat_history +from . import dss_phonecenter_query diff --git a/models/__pycache__/__init__.cpython-311.pyc b/models/__pycache__/__init__.cpython-311.pyc old mode 100644 new mode 100755 index a2b2abf..3ca4060 Binary files a/models/__pycache__/__init__.cpython-311.pyc and b/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/models/__pycache__/company.cpython-311.pyc b/models/__pycache__/company.cpython-311.pyc old mode 100644 new mode 100755 index cd002bc..ec94124 Binary files a/models/__pycache__/company.cpython-311.pyc and b/models/__pycache__/company.cpython-311.pyc differ diff --git a/models/__pycache__/dss.cpython-311.pyc b/models/__pycache__/dss.cpython-311.pyc old mode 100644 new mode 100755 index b799cd4..f5baba8 Binary files a/models/__pycache__/dss.cpython-311.pyc and b/models/__pycache__/dss.cpython-311.pyc differ diff --git a/models/__pycache__/dss_ads.cpython-311.pyc b/models/__pycache__/dss_ads.cpython-311.pyc old mode 100644 new mode 100755 index 4a57736..e55fdc6 Binary files a/models/__pycache__/dss_ads.cpython-311.pyc and b/models/__pycache__/dss_ads.cpython-311.pyc differ diff --git a/models/__pycache__/dss_advertisefields.cpython-311.pyc b/models/__pycache__/dss_advertisefields.cpython-311.pyc old mode 100644 new mode 100755 index 2c5d1a5..a449013 Binary files a/models/__pycache__/dss_advertisefields.cpython-311.pyc and b/models/__pycache__/dss_advertisefields.cpython-311.pyc differ diff --git a/models/__pycache__/dss_binaries.cpython-311.pyc b/models/__pycache__/dss_binaries.cpython-311.pyc old mode 100644 new mode 100755 index 39a8aa4..4789086 Binary files a/models/__pycache__/dss_binaries.cpython-311.pyc and b/models/__pycache__/dss_binaries.cpython-311.pyc differ diff --git a/models/__pycache__/dss_contract.cpython-311.pyc b/models/__pycache__/dss_contract.cpython-311.pyc old mode 100644 new mode 100755 index f21b73b..0f8f5a4 Binary files a/models/__pycache__/dss_contract.cpython-311.pyc and b/models/__pycache__/dss_contract.cpython-311.pyc differ diff --git a/models/__pycache__/dss_contract_uservar.cpython-311.pyc b/models/__pycache__/dss_contract_uservar.cpython-311.pyc old mode 100644 new mode 100755 index f8d4e68..8b5a279 Binary files a/models/__pycache__/dss_contract_uservar.cpython-311.pyc and b/models/__pycache__/dss_contract_uservar.cpython-311.pyc differ diff --git a/models/__pycache__/dss_crm_extended.cpython-311.pyc b/models/__pycache__/dss_crm_extended.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_display_templates.cpython-311.pyc b/models/__pycache__/dss_display_templates.cpython-311.pyc index 46588a8..c4ce1f1 100755 Binary files a/models/__pycache__/dss_display_templates.cpython-311.pyc and b/models/__pycache__/dss_display_templates.cpython-311.pyc differ diff --git a/models/__pycache__/dss_eventdays.cpython-311.pyc b/models/__pycache__/dss_eventdays.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_kileads.cpython-311.pyc b/models/__pycache__/dss_kileads.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_kileads_requests.cpython-311.pyc b/models/__pycache__/dss_kileads_requests.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_linkmail.cpython-311.pyc b/models/__pycache__/dss_linkmail.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_m2mmail.cpython-311.pyc b/models/__pycache__/dss_m2mmail.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_maintains.cpython-311.pyc b/models/__pycache__/dss_maintains.cpython-311.pyc old mode 100644 new mode 100755 index b1eceb7..4a160e9 Binary files a/models/__pycache__/dss_maintains.cpython-311.pyc and b/models/__pycache__/dss_maintains.cpython-311.pyc differ diff --git a/models/__pycache__/dss_marker.cpython-311.pyc b/models/__pycache__/dss_marker.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_onlinestate.cpython-311.pyc b/models/__pycache__/dss_onlinestate.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_projects.cpython-311.pyc b/models/__pycache__/dss_projects.cpython-311.pyc old mode 100644 new mode 100755 index a89a872..08f6a60 Binary files a/models/__pycache__/dss_projects.cpython-311.pyc and b/models/__pycache__/dss_projects.cpython-311.pyc differ diff --git a/models/__pycache__/dss_res_users.cpython-311.pyc b/models/__pycache__/dss_res_users.cpython-311.pyc old mode 100644 new mode 100755 index a9e7b9b..edeb455 Binary files a/models/__pycache__/dss_res_users.cpython-311.pyc and b/models/__pycache__/dss_res_users.cpython-311.pyc differ diff --git a/models/__pycache__/dss_settings.cpython-311.pyc b/models/__pycache__/dss_settings.cpython-311.pyc old mode 100644 new mode 100755 index 03b3abb..0d0ac01 Binary files a/models/__pycache__/dss_settings.cpython-311.pyc and b/models/__pycache__/dss_settings.cpython-311.pyc differ diff --git a/models/__pycache__/dss_settings_variables.cpython-311.pyc b/models/__pycache__/dss_settings_variables.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_systemtypen.cpython-311.pyc b/models/__pycache__/dss_systemtypen.cpython-311.pyc old mode 100644 new mode 100755 index aaefd6a..bd8bb9c Binary files a/models/__pycache__/dss_systemtypen.cpython-311.pyc and b/models/__pycache__/dss_systemtypen.cpython-311.pyc differ diff --git a/models/__pycache__/dss_trigger.cpython-311.pyc b/models/__pycache__/dss_trigger.cpython-311.pyc old mode 100644 new mode 100755 index e82afaf..f4c2a41 Binary files a/models/__pycache__/dss_trigger.cpython-311.pyc and b/models/__pycache__/dss_trigger.cpython-311.pyc differ diff --git a/models/__pycache__/dss_web_contracts.cpython-311.pyc b/models/__pycache__/dss_web_contracts.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_web_contracts_all.cpython-311.pyc b/models/__pycache__/dss_web_contracts_all.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_web_contracts_all2.cpython-311.pyc b/models/__pycache__/dss_web_contracts_all2.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_web_contracts_entry.cpython-311.pyc b/models/__pycache__/dss_web_contracts_entry.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dss_web_contracts_stream.cpython-311.pyc b/models/__pycache__/dss_web_contracts_stream.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/dsslogger.cpython-311.pyc b/models/__pycache__/dsslogger.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/__pycache__/utils.cpython-311.pyc b/models/__pycache__/utils.cpython-311.pyc old mode 100644 new mode 100755 diff --git a/models/company.py b/models/company.py index 2e84708..25291b6 100755 --- a/models/company.py +++ b/models/company.py @@ -28,10 +28,17 @@ class ResCompany(models.Model): dssinternpartner_grafik = fields.Boolean('Grafiker', default=False) dssinternpartner_technik = fields.Boolean('Techniker', default=False) dssportaluser = fields.Many2one('res.users', 'Portal User', domain=[('share','=',True)], help='Verknüpfter Portal Benutzer fuer diesen Partner') + dsspartner_Company_type = fields.Many2one('dss.company.type',string='Firmentyp Kunde',tracking=True) # dss_uuid = fields.Char('uuid') dss_uuid = fields.Char(default=lambda self: self._default_uuid(), required=True, readonly=True, copy=False, string='UUID') rgemail = fields.Char('Rechnungs-Email', default=False) leitwegid = fields.Char('Leitweg-ID', default=False) + google_calendar_id = fields.Char('Google Kalender ID', default=False) + google_calendar_client_id = fields.Char('Google Client ID', default=False) + google_calendar_client_secret = fields.Char('Google Client Secret', default=False) + google_calendar_refresh_token = fields.Char('Google Refresh Token', default=False) + google_calendar_access_token = fields.Char('Google Access Token', default=False) + google_calendar_token_expires_at = fields.Datetime('Google Token gueltig bis', default=False) @api.model def _default_uuid(self): diff --git a/models/dss.py b/models/dss.py index 2858b73..ee92753 100755 --- a/models/dss.py +++ b/models/dss.py @@ -25,6 +25,7 @@ from pyffmpeg import FFmpeg from tuya_iot import TuyaOpenAPI, TUYA_LOGGER from tuya_connector import TuyaOpenAPI, TUYA_LOGGER from . import dss_contract +from . import dss_projects import sys @@ -99,7 +100,28 @@ class dsscontractstatus(models.Model): systemtypdefault = fields.Boolean(string='Default Typ',tracking=True) systemtyp = fields.Many2one('dss.systemtypen',tracking=True) systemtypuuid = fields.Char(related='systemtyp.uuid',tracking=True) - kanban_display_minimal = fields.Boolean(string='Kanban minimalisiert') + kanban_display_minimal = fields.Boolean(string='Kanban minimieren') + kanban_collapsed_choice = fields.Selection( + [('no', 'Nein'), ('yes', 'Ja')], + string='Kanban eingeklappt', + default='no', + store=True, + ) + fold = fields.Boolean( + string='Kanban-Spalte eingeklappt', + compute='_compute_fold', + inverse='_inverse_fold', + store=True, + ) + + @api.depends('kanban_collapsed_choice') + def _compute_fold(self): + for record in self: + record.fold = record.kanban_collapsed_choice == 'yes' + + def _inverse_fold(self): + for record in self: + record.kanban_collapsed_choice = 'yes' if record.fold else 'no' @api.model def _default_uuid(self): @@ -196,7 +218,12 @@ class dssmediatypes(models.Model): maxsize_kb = fields.Integer('Maximale Größe KB') maxsize_w = fields.Integer('Maximale Pixel W') maxsize_h = fields.Integer('Maximale Pixel H') + adportal_visible = fields.Boolean('Ad Portal sichtbar', default=False) + adportal_ki_prompt = fields.Text('KI Prompt') standard_image = fields.Image() + weboptionupload_show = fields.Boolean('Im Uploadformular Anzeigen ?') + weboptionupload_name = fields.Char('Anzeigename für Webformular') + weboptionupload_id = fields.Integer('UploadID für Webformular') default_filename = fields.Char('Default Dateiname', help='Standard-Dateiname für diesen Mediatyp. Wird automatisch aus dem Dateinamen der hochgeladenen Datei abgerufen') can_gen_stamp = fields.Boolean('Kann Stempel generieren', help='Darf durch Stempelgenerator gefüllt werden ja/nein') @@ -360,6 +387,14 @@ class NewBinaryWizard(models.TransientModel): dss_contract.dsscontracts.pyopen_dss_contract_add_binary_form_view_from_wizzard(contract,self.field_ids) return context + def pyopen_dss_project_add_binary_form_view_from_wizzard(self): + context = dict(self.env.context or {}) + default_contract_id = context.get('active_id', False) + _logger.info('Open Add Binary Form '+str(context)+' - '+str(default_contract_id)+' / '+str(self.field_ids)) + project= self.env['dss.projects'].browse(default_contract_id) + dss_projects.dssprojects.pyopen_dss_project_add_binary_form_view_from_wizzard(project,self.field_ids) + return context + class View(models.Model): """ Extends the base 'ir.ui.view' model to include a new type of view diff --git a/models/dss_ads.py b/models/dss_ads.py index fd47de2..1df25b8 100755 --- a/models/dss_ads.py +++ b/models/dss_ads.py @@ -144,6 +144,7 @@ class dsscontractads(models.Model): date_zuarbeit = fields.Date(string='Zuarbeit Datum', help='Zuarbeit gesendet am', tracking=True) + korrekturabzug_cloudlink = fields.Char('Freigabelink zu den fertigen Dateien ',tracking=True) date_korrekturabzug = fields.Date(string='K.Abzug Datum', help='Korrekturabzug gesendet am', tracking=True) date_korrekturfreigabe = fields.Date(string='K.Freigabe Datum', help='Korrekturfreigabe erhalten am', @@ -246,6 +247,24 @@ class dsscontractads(models.Model): autodays=self.env['dss.settings'].search([],limit=1).freigabe_auto_time record.date_korrekturfreigabe_ablauf = record.date_korrekturabzug + relativedelta(days=autodays) + def pysendKA(self): + contract = self.contract + grundsystemname = contract.project.grundsystemname + _logger.info('Korrekturabzug senden : C_' + str(contract.id) + 'GS_' + str(grundsystemname.id)+' / '+str(grundsystemname.email_template_abzug)) + if grundsystemname.email_template_abzug: + _logger.info('Korrekturabzug senden in : C_' + str(contract.id) + 'GS_' + str(grundsystemname.id)+' / '+str(grundsystemname.email_template_abzug)) + newmail = self.env['mail.template'].browse(grundsystemname.email_template_abzug.id).copy() + newmail.email_to = contract.client_short_email + newmail.email_bcc = "projekt@logumedia.de" + newmail.send_mail(contract.id, force_send = True) + self.date_korrekturabzug = date.today() + _logger.info('Korrekturabzug gesendet : C_' + str(contract.id) + 'GS_' + str(grundsystemname.id)+' / '+str(grundsystemname.email_template_abzug)) + else: + _logger.info('Keine Korrekturabzug Mailvorlage hinterlegt : C_' + str(contract.id) + 'GS_' + str(grundsystemname.id)+' / '+str(grundsystemname.email_template_abzug)) + raise ValidationError(_("Keine Korrekturabzug Mailvorlage hinterlegt ! Bitte im Systemtyp eine Mailvorlage für Korrekturabzug hinterlegen !")) + + + def pyaction_view_ad_details(self): action = self.env['ir.actions.act_window'].with_context({'default_adid': self.id})._for_xml_id( 'DigitalSignage.action_dss_ads_view') diff --git a/models/dss_advertisefields.py b/models/dss_advertisefields.py index 56d18ea..126a060 100755 --- a/models/dss_advertisefields.py +++ b/models/dss_advertisefields.py @@ -183,6 +183,13 @@ class dssadvertisefields(models.Model): if not record.contract: record.contract = record.contract_save + def action_remove_contract_links(self): + for record in self: + record.write({ + 'contract': [(5, 0, 0)], + 'contract_save': False, + }) + def pyactiontaketemplate(self): action = (self.env["confirmation.wizard"].confirm_message(_("Das Template wirklich übernehmen ?"),title="Bitte bestätigen",method="taketemplate",records=self,callback_params={"template":self.id}) ) @@ -236,6 +243,45 @@ class dssadvertisefields(models.Model): action['context']='{"active_id":'+str(self.id)+'}' action['res_id']=self.id return action + + @api.model + def action_open_create_from_template_wizard(self): + view = self.env.ref('DigitalSignage.dss_advertisefields_create_from_template_wizard_view_form') + return { + 'type': 'ir.actions.act_window', + 'name': _('Felder aus Vorlage erstellen'), + 'res_model': 'dss.advertisefields.create.from.template.wizard', + 'view_mode': 'form', + 'view_id': view.id, + 'views': [(view.id, 'form')], + 'target': 'new', + } + + @api.model + def action_open_create_from_project_wizard(self): + view = self.env.ref('DigitalSignage.dss_advertisefields_create_from_project_wizard_view_form') + return { + 'type': 'ir.actions.act_window', + 'name': _('Felder aus vorh. Projekt erstellen'), + 'res_model': 'dss.advertisefields.create.from.project.wizard', + 'view_mode': 'form', + 'view_id': view.id, + 'views': [(view.id, 'form')], + 'target': 'new', + } + + @api.model + def action_open_save_as_template_wizard(self): + view = self.env.ref('DigitalSignage.dss_advertisefields_save_as_template_wizard_view_form') + return { + 'type': 'ir.actions.act_window', + 'name': _('Felder in Vorlage speichern'), + 'res_model': 'dss.advertisefields.save.as.template.wizard', + 'view_mode': 'form', + 'view_id': view.id, + 'views': [(view.id, 'form')], + 'target': 'new', + } def btn_image_import(self): action = self.env["confirmation.wizard"].confirm_message(_("Das Bild wirklich importieren ?"),title="Bitte bestätigen",method="btn_image_import_do",records=self,callback_params={"template":self.id}) diff --git a/models/dss_advertisefields_create_from_project_wizard.py b/models/dss_advertisefields_create_from_project_wizard.py new file mode 100755 index 0000000..d10b455 --- /dev/null +++ b/models/dss_advertisefields_create_from_project_wizard.py @@ -0,0 +1,52 @@ +from odoo import fields, models, _ +from odoo.exceptions import ValidationError + + +class DssAdvertisefieldsCreateFromProjectWizard(models.TransientModel): + _name = "dss.advertisefields.create.from.project.wizard" + _description = "Werbefelder aus vorhandenem Projekt erstellen" + + source_project_id = fields.Many2one( + 'dss.projects', + string='Altes Projekt', + required=True, + ) + project_id = fields.Many2one( + 'dss.projects', + string='Neues Projekt', + required=True, + ) + + def action_create_fields(self): + self.ensure_one() + + if self.source_project_id.id == self.project_id.id: + raise ValidationError(_("Altes Projekt und neues Projekt muessen unterschiedlich sein.")) + + source_fields = self.env['dss.advertisefields'].search([ + ('project', '=', self.source_project_id.id) + ]) + if not source_fields: + raise ValidationError(_("Im gewaehlten alten Projekt wurden keine Werbefelder gefunden.")) + + create_vals = [] + project_identifier = self.project_id.projectid or self.project_id.id + for source_field in source_fields: + vals = source_field.copy_data()[0] + feldname = vals.get('feldname') or source_field.feldname + vals.update({ + 'project': self.project_id.id, + 'auto_feldname': f"{project_identifier}_{feldname}", + 'contract': [(6, 0, [])], + 'contract_save': False, + }) + create_vals.append(vals) + + created_fields = self.env['dss.advertisefields'].create(create_vals) + return { + 'type': 'ir.actions.act_window', + 'name': _('Erstellte Werbefelder'), + 'res_model': 'dss.advertisefields', + 'view_mode': 'tree,form', + 'domain': [('id', 'in', created_fields.ids)], + } diff --git a/models/dss_advertisefields_create_from_template_wizard.py b/models/dss_advertisefields_create_from_template_wizard.py new file mode 100755 index 0000000..2fe67aa --- /dev/null +++ b/models/dss_advertisefields_create_from_template_wizard.py @@ -0,0 +1,94 @@ +from odoo import fields, models, _ +from odoo.exceptions import ValidationError + + +class DssAdvertisefieldsCreateFromTemplateWizard(models.TransientModel): + _name = "dss.advertisefields.create.from.template.wizard" + _description = "Werbefelder aus Vorlage erstellen" + + display_template_id = fields.Many2one( + 'dss.display.templates', + string='Vorlage', + required=True, + ) + project_id = fields.Many2one( + 'dss.projects', + string='Neues Projekt', + required=True, + ) + + def action_create_fields(self): + self.ensure_one() + + template_fields = self.env['dss.advertisefields.templates'].search([ + ('displaytemplate', '=', self.display_template_id.id) + ]) + if not template_fields: + raise ValidationError(_("Für die gewählte Vorlage wurden keine Werbefelder gefunden.")) + + create_vals = [] + project_identifier = self.project_id.projectid or self.project_id.id + for template_field in template_fields: + feldname = template_field.feldname or template_field.templatefeldname + create_vals.append({ + 'feldname': feldname, + 'auto_feldname': f"{project_identifier}_{feldname}", + 'project': self.project_id.id, + 'display': template_field.display, + 'color_used': template_field.color_used, + 'color_unused': template_field.color_unused, + 'mediastructure': template_field.mediastructure.id, + 'is_btn': template_field.is_btn, + 'btn_displaytemplate': self.display_template_id.id, + 'btn_fromtemplate': template_field.id, + 'btn_pos_x': template_field.btn_pos_x, + 'btn_pos_y': template_field.btn_pos_y, + 'btn_pos_w': template_field.btn_pos_w, + 'btn_pos_h': template_field.btn_pos_h, + 'btn_name': template_field.btn_name, + 'btn_image': template_field.btn_image, + 'btn_fieldname': template_field.btn_fieldname, + 'btn_visible': template_field.btn_visible, + 'btn_active': template_field.btn_active, + 'btn_action_inactive': template_field.btn_action_inactive, + 'btn_action_active': template_field.btn_action_active, + 'btn_playlist_zuordnung': template_field.btn_playlist_zuordnung, + 'btn_gallery_rows': template_field.btn_gallery_rows, + 'btn_gallery_cols': template_field.btn_gallery_cols, + 'btn_gallery_prv_w': template_field.btn_gallery_prv_w, + 'btn_gallery_prv_h': template_field.btn_gallery_prv_h, + 'btn_has_timer': template_field.btn_has_timer, + 'btn_has_timer_time': template_field.btn_has_timer_time, + 'btn_editable': template_field.btn_editable, + 'btn_special_actionimage': template_field.btn_special_actionimage, + 'btn_special_actionimage_x': template_field.btn_special_actionimage_x, + 'btn_special_actionimage_y': template_field.btn_special_actionimage_y, + 'btn_special_actionimage_w': template_field.btn_special_actionimage_w, + 'btn_special_actionimage_h': template_field.btn_special_actionimage_h, + 'btn_special_actionimage_time': template_field.btn_special_actionimage_time, + 'btn_text_1_visible': template_field.btn_text_1_visible, + 'btn_text_2_visible': template_field.btn_text_2_visible, + 'btn_text_3_visible': template_field.btn_text_3_visible, + 'btn_text_4_visible': template_field.btn_text_4_visible, + 'btn_text_1_pos_x': template_field.btn_text_1_pos_x, + 'btn_text_1_pos_y': template_field.btn_text_1_pos_y, + 'btn_text_2_pos_x': template_field.btn_text_2_pos_x, + 'btn_text_2_pos_y': template_field.btn_text_2_pos_y, + 'btn_text_3_pos_x': template_field.btn_text_3_pos_x, + 'btn_text_3_pos_y': template_field.btn_text_3_pos_y, + 'btn_text_4_pos_x': template_field.btn_text_4_pos_x, + 'btn_text_4_pos_y': template_field.btn_text_4_pos_y, + 'btn_text_1_font': template_field.btn_text_1_font, + 'btn_text_2_font': template_field.btn_text_2_font, + 'btn_text_3_font': template_field.btn_text_3_font, + 'btn_text_4_font': template_field.btn_text_4_font, + }) + + created_fields = self.env['dss.advertisefields'].create(create_vals) + return { + 'type': 'ir.actions.act_window', + 'name': _('Erstellte Werbefelder'), + 'res_model': 'dss.advertisefields', + 'view_mode': 'tree,form', + 'domain': [('id', 'in', created_fields.ids)], + } diff --git a/models/dss_advertisefields_save_as_template_wizard.py b/models/dss_advertisefields_save_as_template_wizard.py new file mode 100755 index 0000000..5a4e18e --- /dev/null +++ b/models/dss_advertisefields_save_as_template_wizard.py @@ -0,0 +1,113 @@ +from odoo import fields, models, _ +from odoo.exceptions import ValidationError + + +class DssAdvertisefieldsSaveAsTemplateWizard(models.TransientModel): + _name = "dss.advertisefields.save.as.template.wizard" + _description = "Werbefelder als Vorlage speichern" + + source_project_id = fields.Many2one( + 'dss.projects', + string='Altes Projekt', + required=True, + ) + new_template_name = fields.Char( + string='Neuer Vorlagenname', + required=True, + ) + + def action_save_as_template(self): + self.ensure_one() + + source_fields = self.env['dss.advertisefields'].search([ + ('project', '=', self.source_project_id.id) + ]) + if not source_fields: + raise ValidationError(_("Im gewaehlten alten Projekt wurden keine Werbefelder gefunden.")) + + template_name = self.new_template_name.strip() + if not template_name: + raise ValidationError(_("Bitte einen gueltigen Vorlagennamen eingeben.")) + + first_display_template = source_fields[0].btn_displaytemplate + display_template_vals = { + 'displayname': template_name, + } + if first_display_template: + display_template_vals.update({ + 'fullsize_w': first_display_template.fullsize_w, + 'fullsize_h': first_display_template.fullsize_h, + 'adportal_visible': first_display_template.adportal_visible, + }) + + new_display_template = self.env['dss.display.templates'].create(display_template_vals) + + template_field_vals = [] + for source_field in source_fields: + template_field_name = source_field.feldname or source_field.auto_feldname or '' + if not source_field.feldname and source_field.auto_feldname and '_' in source_field.auto_feldname: + template_field_name = source_field.auto_feldname.split('_', 1)[1] + + vals = { + 'templatefeldname': template_field_name, + 'display': source_field.display, + 'displaytemplate': new_display_template.id, + 'feldname': source_field.feldname, + 'color_used': source_field.color_used, + 'color_unused': source_field.color_unused, + 'mediastructure': source_field.mediastructure.id, + 'is_btn': source_field.is_btn, + 'btn_pos_x': source_field.btn_pos_x, + 'btn_pos_y': source_field.btn_pos_y, + 'btn_pos_w': source_field.btn_pos_w, + 'btn_pos_h': source_field.btn_pos_h, + 'btn_name': source_field.btn_name, + 'btn_image': source_field.btn_image, + 'btn_fieldname': source_field.btn_fieldname, + 'btn_visible': source_field.btn_visible, + 'btn_active': source_field.btn_active, + 'btn_action_inactive': source_field.btn_action_inactive, + 'btn_action_active': source_field.btn_action_active, + 'btn_playlist_zuordnung': source_field.btn_playlist_zuordnung, + 'btn_gallery_rows': source_field.btn_gallery_rows, + 'btn_gallery_cols': source_field.btn_gallery_cols, + 'btn_gallery_prv_w': source_field.btn_gallery_prv_w, + 'btn_gallery_prv_h': source_field.btn_gallery_prv_h, + 'btn_has_timer': source_field.btn_has_timer, + 'btn_has_timer_time': source_field.btn_has_timer_time, + 'btn_editable': source_field.btn_editable, + 'btn_special_actionimage': source_field.btn_special_actionimage, + 'btn_special_actionimage_x': source_field.btn_special_actionimage_x, + 'btn_special_actionimage_y': source_field.btn_special_actionimage_y, + 'btn_special_actionimage_w': source_field.btn_special_actionimage_w, + 'btn_special_actionimage_h': source_field.btn_special_actionimage_h, + 'btn_special_actionimage_time': source_field.btn_special_actionimage_time, + 'btn_text_1_visible': source_field.btn_text_1_visible, + 'btn_text_2_visible': source_field.btn_text_2_visible, + 'btn_text_3_visible': source_field.btn_text_3_visible, + 'btn_text_4_visible': source_field.btn_text_4_visible, + 'btn_text_1_pos_x': source_field.btn_text_1_pos_x, + 'btn_text_1_pos_y': source_field.btn_text_1_pos_y, + 'btn_text_2_pos_x': source_field.btn_text_2_pos_x, + 'btn_text_2_pos_y': source_field.btn_text_2_pos_y, + 'btn_text_3_pos_x': source_field.btn_text_3_pos_x, + 'btn_text_3_pos_y': source_field.btn_text_3_pos_y, + 'btn_text_4_pos_x': source_field.btn_text_4_pos_x, + 'btn_text_4_pos_y': source_field.btn_text_4_pos_y, + 'btn_text_1_font': source_field.btn_text_1_font, + 'btn_text_2_font': source_field.btn_text_2_font, + 'btn_text_3_font': source_field.btn_text_3_font, + 'btn_text_4_font': source_field.btn_text_4_font, + } + template_field_vals.append(vals) + + created_template_fields = self.env['dss.advertisefields.templates'].create(template_field_vals) + new_display_template.write({'feldtemplates': [(6, 0, created_template_fields.ids)]}) + + return { + 'type': 'ir.actions.act_window', + 'name': _('Neue Werbefeld-Vorlage'), + 'res_model': 'dss.display.templates', + 'view_mode': 'form', + 'res_id': new_display_template.id, + } diff --git a/models/dss_ai_chat_history.py b/models/dss_ai_chat_history.py new file mode 100755 index 0000000..5faebe3 --- /dev/null +++ b/models/dss_ai_chat_history.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models + + +class DssAIChatHistory(models.Model): + _name = "dss.ai.chat.history" + _description = "DigitalSignage AI Chat History" + _order = "create_date desc, id desc" + + user_id = fields.Many2one('res.users', required=True, index=True, default=lambda self: self.env.user) + role = fields.Selection( + [('user', 'User'), ('assistant', 'Assistant')], + required=True, + index=True, + ) + content = fields.Text(required=True) diff --git a/models/dss_binaries.py b/models/dss_binaries.py old mode 100644 new mode 100755 index a294e5b..4d0b903 --- a/models/dss_binaries.py +++ b/models/dss_binaries.py @@ -66,6 +66,7 @@ class dssbinaries(models.Model): binary_secured_ro = fields.Boolean(string="Gesperrt", default=False, help = "Ein Flag, das angibt, ob die Datei gesperrt ist. Wenn dies aktiviert ist, kann die Datei nicht mehr bearbeitet oder gelöscht werden. Sie wird dann auch nicht in Übertragungen oder Ansichten beachtet.") binary_used_ro = fields.Boolean(string="Genutzt", default=False, help = "Ein Flag, das angibt, ob die Datei in einer Übertragung/Ausstrahlung verwendet wird. Wenn dies aktiviert ist, kann die Datei nicht mehr gelöscht oder überarbeitet werden.") binary_contract = fields.Many2one('dss.contracts', string="Vertrag", help = "Der Vertrag, zu dem diese Datei gehört. Dies kann verwendet werden, um Dateien zu organisieren und zu verwalten, die mit bestimmten Verträgen verbunden sind.") + binary_project = fields.Many2one('dss.projects', string="Projekt", help = "Das Projekt, zu dem diese Datei gehört. Dies kann verwendet werden, um Dateien zu organisieren und zu verwalten, die mit bestimmten Projekten verbunden sind.") @@ -122,64 +123,66 @@ class dssbinaries(models.Model): @api.onchange('binary_binary') def _onchange_binary_binary(self): - """Update binary size and type when binary content is changed.""" - _logger.info("New Binary B_"+str(self.id)) - existing_binary = False - if not self._origin.binary_binary: + if self.binary_contract and 1 == 2: + """Update binary size and type when binary content is changed.""" + _logger.info("New Binary B_"+str(self.id)) existing_binary = False - else: - existing_binary = True + if not self._origin.binary_binary: + existing_binary = False + else: + existing_binary = True - if not self.binary_contract.cloudlink: - raise ValidationError(_("Der Vertrag hat keinen Cloudlink. Bitte überprüfen Sie den Vertrag.")) - cloudpath = str(self.binary_contract.cloudlink) - _logger.info("New Binary checking Cloudlink B_"+str(self.id)+" - "+str(cloudpath)) - cloudpath = cloudpath + '/' + str(self.binary_mediatype.cloudlink) - client = Client("https://cloud.logumedia.de/remote.php/dav/files/OdooDav/", auth=("odooClient@logumedia.de", "lm2020#OdooDav")) - _logger.info("New Binary checking Cloudlink for cont : "+str(cloudpath)) - cloudexists = False - if client.exists(cloudpath): - # Convert self.binary_binary to bytes-like object - if isinstance(self.binary_binary, str): - filedata = base64.b64decode(self.binary_binary.split("base64,")[-1]) + if not self.binary_contract.cloudlink: + raise ValidationError(_("Der Vertrag hat keinen Cloudlink. Bitte überprüfen Sie den Vertrag.")) + cloudpath = str(self.binary_contract.cloudlink) + _logger.info("New Binary checking Cloudlink B_"+str(self.id)+" - "+str(cloudpath)) + cloudpath = cloudpath + '/' + str(self.binary_mediatype.cloudlink) + client = Client("https://cloud.logumedia.de/remote.php/dav/files/OdooDav/", auth=("odooClient@logumedia.de", "lm2020#OdooDav")) + _logger.info("New Binary checking Cloudlink for cont : "+str(cloudpath)) + cloudexists = False + if client.exists(cloudpath): + # Convert self.binary_binary to bytes-like object + if isinstance(self.binary_binary, str): + filedata = base64.b64decode(self.binary_binary.split("base64,")[-1]) + else: + filedata = base64.b64decode(self.binary_binary) + filedataStr = str(filedata) + _logger.info("New Binary Cloudlink exists - Saving File - B_"+str(self.id)+" - "+str(cloudpath)+" - " + str(filedata)+' - ' + filedataStr[0:8]) + try: + decoded_data = filedata + except base64.binascii.Error as e: + if 'Incorrect padding' in str(e): + filedataStr += b'=' * (4 - len(filedataStr) % 4) + decoded_data = base64.b64decode(filedataStr) + if filedataStr[0:10] == "b'\x89PNG": + extension = 'png' + else: + extension = 'jpg' + _logger.info("New Binary Cloudlink exists - Saving File - B_"+str(self.id)+" - "+str(cloudpath)+" - " + str(base64.b64encode(decoded_data))) + ##infile = open('/tmp/'+filename, "w+b") + #infile = tempfile.NamedTemporaryFile(delete=False) + ##infile.write(filedata) + ##client.upload_fileobj(infile, f"{cloudpath}/{filename}") + ##infile.close() + ##infile.delete() # Delete the temporary file after upload + cloudexists = True else: - filedata = base64.b64decode(self.binary_binary) - filedataStr = str(filedata) - _logger.info("New Binary Cloudlink exists - Saving File - B_"+str(self.id)+" - "+str(cloudpath)+" - " + str(filedata)+' - ' + filedataStr[0:8]) - try: - decoded_data = filedata - except base64.binascii.Error as e: - if 'Incorrect padding' in str(e): - filedataStr += b'=' * (4 - len(filedataStr) % 4) - decoded_data = base64.b64decode(filedataStr) - if filedataStr[0:10] == "b'\x89PNG": - extension = 'png' - else: - extension = 'jpg' - _logger.info("New Binary Cloudlink exists - Saving File - B_"+str(self.id)+" - "+str(cloudpath)+" - " + str(base64.b64encode(decoded_data))) - ##infile = open('/tmp/'+filename, "w+b") - #infile = tempfile.NamedTemporaryFile(delete=False) - ##infile.write(filedata) - ##client.upload_fileobj(infile, f"{cloudpath}/{filename}") - ##infile.close() - ##infile.delete() # Delete the temporary file after upload - cloudexists = True - else: - _logger.info("New Binary Cloudlink does not exists B_"+str(self.id)+" - "+str(cloudpath)) - raise ValidationError(_("Der Cloudlink im Vertrag ist nicht vorhanden. Bitte überprüfen Sie den Link ggf. anlegen.")) + _logger.info("New Binary Cloudlink does not exists B_"+str(self.id)+" - "+str(cloudpath)) + raise ValidationError(_("Der Cloudlink im Vertrag ist nicht vorhanden. Bitte überprüfen Sie den Link ggf. anlegen.")) - if cloudexists: - if self.binary_binary: - # Calculate the size of the binary content - self.binary_size = len(filedata) - # Determine the file type based on the binary content - self.binary_cloud_link = f"{cloudpath}/{self.binary_filename}" - self.binary_issaved = True - else: - self.binary_size = 0 - self.binary_type = '' - self.binary_cloud_link = '' - self.binary_issaved = False + if cloudexists: + if self.binary_binary: + # Calculate the size of the binary content + self.binary_size = len(filedata) + # Determine the file type based on the binary content + self.binary_cloud_link = f"{cloudpath}/{self.binary_filename}" + self.binary_issaved = True + else: + self.binary_size = 0 + self.binary_type = '' + self.binary_cloud_link = '' + self.binary_issaved = False + def dload(self): diff --git a/models/dss_company_type.py b/models/dss_company_type.py new file mode 100755 index 0000000..2ba596f --- /dev/null +++ b/models/dss_company_type.py @@ -0,0 +1,74 @@ +import ast +import datetime +import json +import re +import uuid +import logging +import base64 +import subprocess +import tempfile +import easywebdav +import os +import os.path +import requests +import hashlib +from PIL import Image,ImageDraw,ImageFont + +from odoo import api, fields, models, _ +from odoo import tools +from odoo.exceptions import ValidationError +from datetime import date +from datetime import datetime +from dateutil.relativedelta import relativedelta +from pyffmpeg import FFmpeg +from tuya_iot import TuyaOpenAPI, TUYA_LOGGER +from tuya_connector import TuyaOpenAPI, TUYA_LOGGER +from webdav4.client import Client +from webdav4.fsspec import WebdavFileSystem +import sys + +TUYA_LOGGER.setLevel(logging.DEBUG) +_logger = logging.getLogger(__name__) + + +class dsscompanytype(models.Model): + + @api.model + def create(self,vals): + result = super().create(vals) + return result + + _name = "dss.company.type" + _description = "DigitalSignage Firmentypen" +# _inherit = ['mail.thread','mail.activity.mixin'] + _rec_name = "Firmetyp_name" +# _inherit = ['mail.thread', 'mail.activity.mixin'] + + uuid = fields.Char(default=lambda self: self._default_uuid(), required=True, readonly=True, copy=False, string='UUID') + date_create = fields.Date('Erstellungsdatum',default=lambda self: self._default_create_date()) + date_lastedit = fields.Date('Änderungsdatum') + user_create = fields.Char('Erstellungsuser',default=lambda self: self._default_create_user()) + user_lastedit = fields.Char('Änderungsuser') + Firmetyp_name = fields.Char(string="Firmetyp Name", required=True, help = "Der Name des Firmetyps, die Sie hochladen möchten. Dieser Name wird in der Benutzeroberfläche angezeigt.") + firmentyp_needs_suffix = fields.Boolean(string="Benötigt Suffix", help="Gibt an, ob der Firmentyp ein Suffix benötigt") + firmentyp_suf = fields.Char(string="Firmetyp Suffix", help="Suffix für den Firmentyp im Namen") + firmentyp_turn_data = fields.Boolean(string="Turn Data", help="Gibt an, ob der Firmenname und Name getauscht werden müssen") + + @api.model + def _default_uuid(self): + return str(uuid.uuid4()) + + @api.model + def _default_create_date(self): + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + @api.model + def _default_create_user(self): + return self.env.user.name if self.env.user else 'System' + + @api.model + def _default_binary_id(self): + # Generate a unique binary ID based on the current timestamp and a random UUID + return f"Datei_{str(uuid.uuid4())[:8]}" + + diff --git a/models/dss_contract.py b/models/dss_contract.py index 48ad32c..1db2dff 100755 --- a/models/dss_contract.py +++ b/models/dss_contract.py @@ -16,6 +16,7 @@ import tempfile import easywebdav import os import os.path +import kontocheck from PIL import Image,ImageDraw,ImageFont @@ -33,6 +34,7 @@ from tuya_iot import TuyaOpenAPI, TUYA_LOGGER from tuya_connector import TuyaOpenAPI, TUYA_LOGGER from webdav4.client import Client from webdav4.fsspec import WebdavFileSystem +#from dss_trigger import trigger import sys TUYA_LOGGER.setLevel(logging.DEBUG) @@ -85,7 +87,7 @@ class dsscontracts(models.Model): _rec_name = "contract_auto_name" _inherit = ['mail.thread','dss.activity.mixin','dss.triggermodel'] uuid = fields.Char(default=lambda self: self._default_uuid(), required=True, readonly=True, copy=False, string='UUID') - contract_creator = fields.Many2one('res.users', string='Vertrag erstellt von', default=lambda self: self.env.user, readonly=True) + contract_creator = fields.Many2one('res.users', string='Vertrag erstellt von', default=lambda self: self.env.user) contract_create_date = fields.Datetime('Vertrag erstellt am', default=lambda self: fields.Datetime.now(), readonly=True) cruuid = fields.Char(related='uuid') contract_id = fields.Char("Kundennummer",store=True,tracking=True) @@ -94,7 +96,8 @@ class dsscontracts(models.Model): contract_state_name = fields.Char(related='contract_state.statusname',string='Vertragsstatus - Text',store=True) contract_state_color = fields.Char(related='contract_state.color',string='Vertragsstatus - Farbe',store=True) contract_state_order = fields.Integer(related='contract_state.order',string='Vertragsstatus - Reihenfolge',store=True) - contract_state_minimal_kanban = fields.Boolean(related='contract_state.kanban_display_minimal',string='Kanban mininmal',store=True) + contract_state_minimal_kanban = fields.Boolean(related='contract_state.kanban_display_minimal',string='Kanban minimieren',store=True) + contract_state_kanban_collapsed = fields.Selection(related='contract_state.kanban_collapsed_choice', string='Kanban eingeklappt', store=True) contract_auto_id = fields.Char("Kundennummer",tracking=True,help="Wird berechnet aus Projektnummer + Kunden ID") contract_auto_name = fields.Char('Vertragskennug',tracking=True,help="Wird berechnet aus Kundennummer + Vertragskennung") @@ -109,6 +112,9 @@ class dsscontracts(models.Model): contract_receipt_done_date = fields.Date('Rechnungsdatum',tracking=True) contract_receipt_done_until_date = fields.Date('Zahlungsziel', tracking=True) + client_is_export_finance = fields.Boolean('An Buchhaltung exportiert ?', tracking=True) + client_is_export_finance_date = fields.Date('An Buchhaltung exportiert am', tracking=True) + client_is_inkaso = fields.Boolean('Ist Inkasso eröffnet ?', tracking=True) client_inkaso_datum = fields.Datetime('Inkasso eröffnet am ', tracking=True) client_inkaso_info = fields.Char('Inkasso Bemerkungen', tracking=True) @@ -144,7 +150,9 @@ class dsscontracts(models.Model): project_grundsystem_typ_cloud_contract_template = fields.Char(related='project.grundsystem_default_cloud_structure_contract', tracking=True) project_grafiker = fields.Many2one(related="project.project_grafiker",tracking=True) project_system_uuid = fields.Char(related='project.grundsystemnameuuid', tracking=True) - + project_techniker = fields.Many2one(related='project.project_techniker', string='Aufbau') + techniker_computed = fields.Many2one('res.partner', compute='_compute_techniker_computed', string='Techniker tatsächlich') + run_trigger = fields.Boolean(tracking=True) run_uni_trigger = fields.Boolean(tracking=True) run_uni_sub_trigger = fields.Boolean(tracking=True) @@ -168,10 +176,12 @@ class dsscontracts(models.Model): contact_name = fields.Char(related="client.name") contact_dsspartner_vorname = fields.Char(related="client.dsspartner_vorname") contact_dsspartner_name = fields.Char(related="client.dsspartner_name") + contact_company_type = fields.Many2one(related="client.dsspartner_Company_type") parent_id = fields.Many2one('dss.contracts', string='Parent Task', index=True,tracking=True) client_short_company = fields.Char('Firmenname Kunde',tracking=True) + client_short_company_type = fields.Many2one('dss.company.type',string='Firmentyp Kunde',tracking=True) client_short_vorname = fields.Char('Vorname Kunde',tracking=True) client_short_name = fields.Char('Name Kunde',tracking=True) client_short_strasse = fields.Char('Strasse Kunde',tracking=True) @@ -188,6 +198,9 @@ class dsscontracts(models.Model): client_short_leitwegid = fields.Char('Leitwegid Kunde',tracking=True) client_short_BIC = fields.Char('BIC Kunde',tracking=True) client_short_IBAN = fields.Char('IBAN Kunde',tracking=True) + client_short_BANK_computed = fields.Char(string='Bank Kunde',compute='_compute_cutshort_Bank') + client_short_KONTO_computed = fields.Char(string='Konto Kunde',compute='_compute_cutshort_Konto') + client_short_BLZ_computed = fields.Char(string='BLZ Kunde',compute='_compute_cutshort_BLZ') client_other_projects = fields.Many2many('dss.projects',string='Weitere Projekte',tracking=True) @@ -220,12 +233,17 @@ class dsscontracts(models.Model): contract_date = fields.Date('Vertragsdatum',tracking=True) start_date = fields.Date('Start/Auslief.datum',tracking=True,help="Datum des geplanten Startes der 1. Kampagne. Also die Uraustrahlung!") + calc_end_date = fields.Date('Enddatum berechnet',tracking=True,help="Berechnetes Enddatum der Kampagne basierend auf Startdatum und Laufzeit (Ändert sich bei Verlängerungen).") contract_cancel_mon = fields.Integer('Kündigungsfrist Monaten',tracking=True) contract_cancel_date = fields.Date('Kündigungsfrist Errechnet', tracking=True) contract_iscanceled = fields.Boolean('Vertrag gekündigt',tracking=True) contract_iscanceled_date = fields.Date('Vertrag gekündigt am',tracking=True) + contract_iscanceled_logumedia = fields.Boolean('Vertrag gekündigt durch Logumedia',tracking=True) + contract_iscanceled_logumedia_date = fields.Date('Vertrag von Logumedia gekündigt am ',tracking=True) + contract_iscanceled_logumedia_reason = fields.Char('Vertrag von Logumedia gekündigt Grund',tracking=True) contract_auto_extend = fields.Boolean('autom. V.Verlängerung',tracking=True) contract_auto_extend_time = fields.Char('Verl. Monate',tracking=True) + contract_auto_extend_count = fields.Integer('Anzahl Vertragsverlängerungen', tracking=True, default=0) scan_vertrag = fields.Binary('Datei') scan_vertrag_filename = fields.Char("Dateiname") # Hilft, den Dateinamen zu speichern @@ -279,6 +297,7 @@ class dsscontracts(models.Model): vnnox_zugang_username = fields.Char('Vnnox Username',tracking=True) vnnox_zugang_password = fields.Char('Vnnox Passwort',tracking=True) vnnox_zugang_gesendet = fields.Boolean('Vnnox Zugang gesendet?',tracking=True) + vnnox_playlist_checked = fields.Boolean('Vnnox Liste geprüft ?',tracking=True) vnnox_playlist_checked_empty = fields.Boolean('Vnnox Liste war leer ?',tracking=True) vnnox_playlist_checked_date = fields.Date('Vnnox Liste geprüft am ',tracking=True) @@ -291,6 +310,7 @@ class dsscontracts(models.Model): lmw_zugang_erstellt = fields.Boolean('LMW Zugang ?',tracking=True) lmw_zugang_username = fields.Char('LMW Username',tracking=True) lmw_zugang_password = fields.Char('LMW Passwort',tracking=True) + lmw_zugang_gesendet = fields.Boolean('LMW Zugang gesendet?',tracking=True) wflow_korrekturabzug = fields.Boolean('Korekturabzug gesendet ?',tracking=True) @@ -342,9 +362,9 @@ class dsscontracts(models.Model): cloudlink = fields.Char('Cloud Verzeichnis',help='Verzeichnis für den Kunde innerhalb des Projekt Ordners') web_contract = fields.Many2one('dss.web_contracts' , string='Web_Vertrag', store=True,tracking=True) web_contract_input_notfinished = fields.Boolean('Web Vertrag Eingabe nicht abgeschlossen',tracking=True) - web_contract_input_finished = fields.Boolean('Web Vertrag Eingabe abgeschlossen',tracking=True) + web_contract_input_finished = fields.Boolean('Web Vertrag Eingabe war abgeschlossen',tracking=True) web_contract_paysum_gfx = fields.Float('Web Vertrag Zahlungssumme Grafik',tracking=True) - web_contract_paysum_lz = fields.Float('Web Vertrag Zahlungssumme Laufzeit',tracking=True) + web_contract_paysum_lz = fields.Float('Web Vertrag Zahlungssumme Laufzeit',tracking=True) web_contract_paysum_all = fields.Float('Web Vertrag Zahlungssumme Gesamt',tracking=True) web_contract_sign_ort = fields.Char('Web Vertrag Ort',tracking=True) web_contract_sign_datum = fields.Char('Web Vertrag Datum',tracking=True) @@ -355,15 +375,115 @@ class dsscontracts(models.Model): web_contract_sonder_0 = fields.Char('Web Vertrag Sonderfeld 01',tracking=True) web_contract_sonder_1 = fields.Char('Web Vertrag Sonderfeld 02',tracking=True) web_contract_sonder_2 = fields.Char('Web Vertrag Sonderfeld 03',tracking=True) + web_contract_satzmail = fields.Boolean('Web Vertrag Satzmaterial als Mail',tracking=True) + web_contract_satzgiven = fields.Boolean('Web Vertrag Satzmaterial gegeben',tracking=True) + web_contract_rgmail = fields.Boolean('Web Vertrag Rechnung als Mail',tracking=True) + web_contract_kamail = fields.Boolean('Web Vertrag Korrekturabzug Zugang',tracking=True) + web_constract_zahlstonst = fields.Char('Web Vertrag Zahlungsweise',tracking=True) + web_contract_rejected = fields.Boolean('Eingabe - Web Vertrag abgelehnt',tracking=True) + web_contract_reject_reason = fields.Char('Eingabe - Web Vertrag Ablehnungsgrund',tracking=True) + web_bande_spieltage = fields.Char('Web Vertrag Bande - gebuchte Spieltage',tracking=True) + web_contract_start_planed = fields.Char("TV Kampagne Start",tracking=True) + web_contract_remove_planed = fields.Char("TV Kampagne Ende",tracking=True) + tv_reach_PLZ = fields.Char(string='Reichweite PLZ',tracking=True) ads_radius_PLZ = fields.Integer('Umkreis PLZ in Km',tracking=True) ads_count_perYear = fields.Selection([('30000','30.000'),('60000','60.000'),('120000','120.000'),('1000000','1.000.000')],'Einblendungen alt',tracking=True) ads_count_perYear2 = fields.Integer('Einblendungen',tracking=True) ads_topics = fields.Many2many('dss.contracts_ads_topics', string='Themenliste', tracking=True) ads_topics_text = fields.Char('Themenliste gesamt',compute='_compute_themenliste') - + userdef_vars = fields.Many2many('dss.contracts_uservars', string='Vertrags Uservariablen', tracking=True) + contract_finish_img_cloud = fields.Char('Überg. Bildlink',tracking=True) + + @api.depends('techniker','project_techniker') + @api.onchange('techniker') + @api.onchange('project_techniker') + def _compute_techniker_computed(self): + for record in self: + if record.techniker: + record.techniker_computed = record.techniker + elif record.project_techniker: + record.techniker_computed = record.project_techniker + else: + record.techniker_computed = False + + @api.depends('client_short_IBAN') + @api.onchange('client_short_IBAN') + @api.onchange('client_short_BIC') + def _compute_cutshort_Bank(self): + # BIC für Deutsche Bank + kontocheck.lut_load() + for record in self: + bankname ='' + if record.client_short_BIC: + try: + bankname = kontocheck.scl_get_bankname(record.client_short_BIC) + except ValueError: + bankname = '' + else : + if record.client_short_BLZ_computed: + try: + bankname = kontocheck.get_bankname(record.client_short_BLZ_computed) + except ValueError: + bankname = '' + + record.client_short_BANK_computed = bankname + + + def get_account_from_iban(iban): + # Entfernen von Leerzeichen und Großbuchstaben + clean_iban = "".join(iban.split()).upper() + + # Überprüfen, ob es eine deutsche IBAN ist + if len(clean_iban) == 22 and clean_iban.startswith("DE"): + # Die letzten 10 Stellen sind die Kontonummer + account_number = clean_iban[-10:] + return account_number + else: + return "Ungültige deutsche IBAN" + + def get_blz_from_iban(iban): + # Entfernen von Leerzeichen und Großbuchstaben + clean_iban = "".join(iban.split()).upper() + + # Überprüfen, ob es eine deutsche IBAN ist + if len(clean_iban) == 22 and clean_iban.startswith("DE"): + # Die letzten 10 Stellen sind die Kontonummer + blz_number = clean_iban[4:12] + return blz_number + else: + return "Ungültige deutsche IBAN" + + @api.depends('client_short_IBAN') + @api.onchange('client_short_IBAN') + def _compute_cutshort_Konto(self): + kontocheck.lut_load() + for record in self: + Kontonummer = '' + if record.client_short_IBAN: + try: + Kontonummer = dsscontracts.get_account_from_iban(record.client_short_IBAN) + except ValueError: + Kontonummer = '' + + record.client_short_KONTO_computed = Kontonummer + + @api.depends('client_short_IBAN') + @api.onchange('client_short_IBAN') + def _compute_cutshort_BLZ(self): + kontocheck.lut_load() + for record in self: + Bankleitzahl = '' + if record.client_short_IBAN: + try: + Bankleitzahl = dsscontracts.get_blz_from_iban(record.client_short_IBAN) + except ValueError: + Bankleitzahl = '' + record.client_short_BLZ_computed = Bankleitzahl + + @api.depends('work_marker_1') @api.onchange('work_marker_1') def _get_marker_1_color_false(self): @@ -706,7 +826,7 @@ class dsscontracts(models.Model): myplz=self.client_short_plz result = self.env['res.partner'].create({'name':fullname, 'company_name': self.client_short_company, - 'dsspartner_vorname':self.client_short_vorname, + 'dsspartner_vorname':self.client_short_vorname, 'dsspartner_name':self.client_short_name, 'dsspartner':True, 'dsspartner_werbung':True, @@ -719,6 +839,7 @@ class dsscontracts(models.Model): 'rgemail':self.client_short_rgemail, 'is_company':False}) result.country_id = self.client_short_land + result.dsspartner_Company_type = self.client_short_company_type.id _logger.info('Creating Contact ' + str(result) + ' ' + str(fullname)) self.client = result # action = self.env['ir.actions.act_window'].with_context({'default_contractid': self.id})._for_xml_id('DigitalSignage.act_dss_project_view_contract') @@ -914,6 +1035,9 @@ class dsscontracts(models.Model): defadstateid = defadstate.id kampagne = self.env['dss.ads'].create({'contract': self.id, 'project': self.project.id, 'adname': 'WK '+self.contract_auto_name,'adtype':'MAIN','ad_state':defadstateid}) self.ads_last_ad = kampagne.id + if self.web_contract_start_planed : + kampagne.date_start_planed = self.web_contract_start_planed + kampagne.date_remove_planed = self.web_contract_remove_planed _logger.info('Click auf Werbekampagne : C_'+str(self.id)+' Kampagne erstellt A_'+str(kampagne.id)) kampagne.parent_ad = kampagne.id mediaids = [] @@ -1018,6 +1142,7 @@ class dsscontracts(models.Model): else: _logger.info('Prüfe Medien : C_'+str(self.id)+'A_' +str(kamp.id)+' Keine Kampagne gefunden !') # _logger.info('Click auf Werbekampagne : '+str(self.id)+' 4 '+str(kampagne.id)) + return { 'type': 'ir.actions.act_window', 'view_type':'form', @@ -1054,6 +1179,91 @@ class dsscontracts(models.Model): if record.contract_auto_id == "": record.contract_auto_id = cidstr + def _compute_contract_end_for_calc_date(self): + self.ensure_one() + + start_date = self.start_date or self.contract_date + if not start_date: + return False + + if self.contract_iscanceled and self.ads_last_todo_state_until: + return self.ads_last_todo_state_until + + runtime_months = int(self.runtime_m or 0) + int(self.runtime_bonus_m or 0) + if self.contract_auto_extend: + try: + runtime_months += int((self.contract_auto_extend_time or '0').strip() or 0) + except Exception: + pass + + return start_date + relativedelta(months=runtime_months) + + def action_update_calc_contract_end_selected(self): + updated_count = 0 + for record in self: + new_end_date = record._compute_contract_end_for_calc_date() + if record.calc_end_date != new_end_date: + record.calc_end_date = new_end_date + updated_count += 1 + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Vertragsende aktualisiert'), + 'message': _('%s Vertrag(e) wurden aktualisiert.') % updated_count, + 'type': 'success', + 'sticky': False, + } + } + + def action_remove_selected_fields(self): + selected_fields = self.mapped('werbe_feld_selected') + contract_ids = self.ids + + self.write({'werbe_feld_selected': [(5, 0, 0)]}) + + for ad_field in selected_fields: + remove_cmds = [(3, contract_id) for contract_id in contract_ids if contract_id in ad_field.contract.ids] + if remove_cmds: + ad_field.write({'contract': remove_cmds}) + if ad_field.contract_save and ad_field.contract_save.id in contract_ids: + ad_field.contract_save = ad_field.contract[:1].id or False + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Felder entfernt'), + 'message': _('%s Vertrag(e) wurden von ihren Werbefeldern getrennt.') % len(self), + 'type': 'success', + 'sticky': False, + } + } + + def action_open_move_to_fallback_field_wizard(self): + if not self: + raise ValidationError(_("Bitte mindestens einen Vertrag auswaehlen.")) + + projects = self.mapped('project') + if len(projects) != 1: + raise ValidationError(_("Bitte nur Vertraege aus einem Projekt gleichzeitig auswaehlen.")) + + view = self.env.ref('DigitalSignage.dss_contracts_move_field_wizard_view_form') + return { + 'type': 'ir.actions.act_window', + 'name': _('Felder auf ausweichfeld umsetzen'), + 'res_model': 'dss.contracts.move.field.wizard', + 'view_mode': 'form', + 'view_id': view.id, + 'views': [(view.id, 'form')], + 'target': 'new', + 'context': { + 'active_ids': self.ids, + 'default_project_id': projects.id, + }, + } + # def setRealGrafiker(self): #for record in self: #if self.grafiker: @@ -1165,6 +1375,212 @@ class dsscontracts(models.Model): ) if action: return action + + def export_kd_csv_all_not_work(self): + _logger.info('Export Kunden CSV : C_'+str(self.id)) + action = (self.env["confirmation.wizard"].confirm_message(_("Soll der Kunde einzeln übertragen werden ?"),title="Bitte bestätigen",method="export_kd_csv",records=self,callback_params={"template":self.id})) + if action: + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - einzeln bestätigt ') + csv = 'Kundenname;Firma;Strasse;Ort;PLZ;Telefon;Mobil;Email;RG-Email;Leitwegid;Company Type\n' + #csv += str(self.client_short_vorname) + ' ' + str(self.client_short_name) + ';' + str(self.client_short_company) + ';' + str(self.client_short_strasse) + ';' + str(self.client_short_ort) + ';' + str(self.client_short_plz) + ';' + str(self.client_short_telefon) + ';' + str(self.client_short_mobil) + ';' + str(self.client_short_email) + ';' + str(self.client_short_rgemail) + ';' + str(self.client_short_leitwegid) + ';' + str(self.client_short_company_type.name if self.client_short_company_type else '')+'\n' + else: + action = (self.env["confirmation.wizard"].confirm_message(_("Soll der Kunde zur Liste der Übertragungen hinzugefügt werden ?"),title="Bitte bestätigen",method="export_kd_csv",records=self,callback_params={"template":self.id})) + if action: + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Hinzufügen bestätigt ') + action = (self.env["confirmation.wizard"].confirm_message(_("Liste jetzt als CSV erstellen ?"),title="Bitte bestätigen",method="export_kd_csv",records=self,callback_params={"template":self.id})) + if action: + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - CSV bestätigt ') + + @staticmethod + def _split_text_for_csv(value, first_max=40, second_max=40): + text = str(value or '').strip() + if len(text) <= first_max: + return text, '' + + boundary_cut = -1 + remainder_start = first_max + for match in re.finditer(r'[\s\W_]+', text[:first_max + 1]): + if 0 < match.start() <= first_max: + boundary_cut = match.start() + remainder_start = match.end() + + # If no useful separator exists near the limit, fallback to strict split. + if boundary_cut < int(first_max * 0.6): + first_line = text[:first_max] + second_line = text[first_max:] + else: + first_line = text[:boundary_cut] + second_line = text[remainder_start:] + + return first_line, second_line.lstrip()[:second_max] + + @api.model + def checkattachents(self,project,contract): + _logger.info('Check Attachments : C_'+str(contract)+' - P_'+str(project)) + html = "" + if project.contract_finish_img_cloud: + _logger.info('Check Attachments : C_'+str(contract)+' - P_'+str(project)+' - Projekt hat Cloudbilder :'+str(project.contract_finish_img_cloud)) + return True + else: + if contract.contract_finish_img_cloud: + _logger.info('Check Attachments : C_'+str(contract)+' - P_'+str(project)+' - Vertrag hat Cloudbilder :'+str(contract.contract_finish_img_cloud)) + return True + else: + return False + + @staticmethod + def _encode_csv_attachment(csv_text): + # UTF-8 with BOM keeps umlauts/special chars readable in Excel and downstream tools. + normalized = str(csv_text or '').replace('\r\n', '\n').replace('\r', '\n') + return base64.b64encode(normalized.encode('utf-8-sig')) + + + def _export_kd_csv_single(self,record_id): + dataset=self.env['dss.contracts'].browse(record_id) + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Einzelner Export ') + company_name_line_1, company_name_line_2 = self._split_text_for_csv(dataset.client_short_company) + csv = '"DEBITORENKONTO";"Kunde_seit";"ZAHLUNGSART";"SKONTOTYP";"ANREDE";"akad.Grad";"TITEL1";"AP-VORNAME";"AP-NAME";"NAME";"Ergaenzung";"LAND";"STRASSE";"PLZ";"ORT";"TELEFON1";"FAX1";"Mobil1";"INTERNET";"EMAIL1";"TELEFON1_AP";"KUNDENNR";"BANKNAME_1";"BANKKONTO_1";"BLZ_1";"IBAN_1";"BIC_1";"FIBU-KONTENBEZEICHNUNG";"KREDITLIMIT";"HAUSBANK";"FAELLIG_TG"'+'\n' + csv += ('"'+str(dataset.contract_auto_id) + + '";"' + str(dataset.contract_date.strftime('%d.%m.%Y')) + + '";"' + ('L' if dataset.web_contract_is_lastschrift else '') + + '";"' + str("4") + + '";"' + str("") + + '";"' + str("") + + '";"' + str("") + + '";"' + str(dataset.client_short_vorname) + + '";"' + str(dataset.client_short_name) + + '";"' + str(company_name_line_1) + + '";"' + str(company_name_line_2) + + '";"' + str(dataset.client_short_land.name if dataset.client_short_land else 'DEUTSCHLAND') + + '";"' + str(dataset.client_short_strasse) + + '";"' + str(dataset.client_short_plz) + + '";"' + str(dataset.client_short_ort) + + '";"' + str(dataset.client_short_telefon) + + '";"' + str(dataset.runtime_m) + + '";"' + str(dataset.client_short_mobil) + + '";"' + str(dataset.cutshortwerbe_feld_selected) + + '";"' + str(dataset.client_short_email) + + '";"' + str("") + + '";"' + str(dataset.contract_auto_id) + + '";"' + str(dataset.client_short_BANK_computed) + + '";"' + str(dataset.client_short_KONTO_computed) + + '";"' + str(dataset.client_short_BLZ_computed) + + '";"' + str(dataset.client_short_IBAN).upper() + + '";"' + str(dataset.client_short_BIC).upper() + + '";"' + str(re.sub(r'[^a-zA-Z0-9]', '', str(dataset.client_short_company.upper().strip()))[:20]) + + '";"' + str(dataset.web_contract_paysum_all) + + '";"' + str('2' if dataset.web_contract_is_lastschrift else '1') + + '"8"'+ + '"\n') + _logger.info('Export Kunden CSV : C_'+str(dataset.id)+' - CSV Inhalt : \n'+str(csv)) + #csv = csv.replace('"False"', '""') + filename = 'KDEXPORT_' + str(dataset.contract_auto_id) + '_' + str(uuid.uuid4())[:8] + '.csv' + attachment_id = self.env['ir.attachment'].create({ 'name': filename, 'datas': self._encode_csv_attachment(csv),'res_model': 'dss.contracts', 'res_id': dataset.id}) + base_url = self.env['ir.config_parameter'].get_param('web.base.url') + download_url = '/web/content/' + str(attachment_id.id) + '?download=true' + self.client_is_export_finance_date = datetime.now() + self.client_is_export_finance = True + # + # this does the download + return { + "type": "ir.actions.act_url", + "url": str(base_url) + str(download_url), + "target": "new" + } + + def export_kd_csv_single(self,data_id=None): + if not self.client_is_export_finance or data_id: + self._export_kd_csv_single(self.id) + else: + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Bereits exportiert am '+str(self.client_is_export_finance_date)) + action = (self.env["confirmation.wizard"].confirm_message(_("Der Kunde wurde bereits am "+str(self.client_is_export_finance_date)+" exportiert ! Soll der Export erneut durchgeführt werden ?"),title="Bitte bestätigen",method="export_kd_csv_single",records=self,callback_params={"data_id":self.id})) + if action: + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Einzelner Export erneut bestätigt ') + self.client_is_export_finance = False + return self.export_kd_csv_single() + else: + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Einzelner Export erneut abgelehnt ') + + def _export_kd_csv_add(self,record_ids): + dataset=self.env['dss.contracts'].browse(record_ids) + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Hinzufügen ') + existing_user_var = self.env['dss.contracts_uservars'].search([('contract', '=', self.id),('var_ID','=','BHEXMARK_1')], limit=1) + if not existing_user_var: + uvar = self.env['dss.contracts_uservars'].create({'contract': self.id, 'var_ID': 'BHEXMARK_1', 'var_name':'Buchhaltung Export vorgemerkt !', 'var_value': str(datetime.now())}) + self.userdef_vars = [(4, uvar.id)] + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Hinzufügen - Uservar erstellt ') + + def export_kd_csv_add(self,data_id=None): + if not self.client_is_export_finance or data_id: + self._export_kd_csv_add(self.id) + else: + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Bereits exportiert am '+str(self.client_is_export_finance_date)) + action = (self.env["confirmation.wizard"].confirm_no_action_message(_("Der Kunde wurde bereits am "+str(self.client_is_export_finance_date)+" exportiert ! "),title="Schon exportiert !")) + return action + + def export_kd_csv_send(self): + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Senden ') + existing_user_var = self.env['dss.contracts_uservars'].search([('var_ID','=','BHEXMARK_1')]) + Text = 'Export Kunden CSV es wurden folgende Kunden gefunden und übertragen : \n' + csv = '"DEBITORENKONTO";"Kunde_seit";"ZAHLUNGSART";"SKONTOTYP";"ANREDE";"akad.Grad";"TITEL1";"AP-VORNAME";"AP-NAME";"NAME";"Ergaenzung";"LAND";"STRASSE";"PLZ";"ORT";"TELEFON1";"FAX1";"Mobil1";"INTERNET";"EMAIL1";"TELEFON1_AP";"KUNDENNR";"BANKNAME_1";"BANKKONTO_1";"BLZ_1";"IBAN_1";"BIC_1";"FIBU-KONTENBEZEICHNUNG";"HAUSBANK";"FAELLIG_TG"'+'\n' + for contract_user_var in existing_user_var: + contract=contract_user_var.contract + if contract: + dataset=contract + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Einzelner Export ') + company_name_line_1, company_name_line_2 = self._split_text_for_csv(dataset.client_short_company) + csv += ('"'+str(dataset.contract_auto_id) + #DEBITORENKONTO + '";"' + str(dataset.contract_date.strftime('%d.%m.%Y')) + #Kunde_seit + '";"' + ('L' if dataset.web_contract_is_lastschrift else '') + #ZAHLUNGSART + '";"' + str("4") + #SKONTOTYP + '";"' + str("") + #ANREDE + '";"' + str("") + #akad.Grad + '";"' + str("") + #TITEL1 + '";"' + str(dataset.client_short_vorname) + #AP-VORNAME + '";"' + str(dataset.client_short_name) + #AP-NAME + '";"' + str(company_name_line_1) + #NAME + '";"' + str(company_name_line_2) + #Ergaenzung + '";"' + str(dataset.client_short_land.name if dataset.client_short_land else 'DEUTSCHLAND') + #LAND + '";"' + str(dataset.client_short_strasse) + #STRASSE + '";"' + str(dataset.client_short_plz) + #PLZ + '";"' + str(dataset.client_short_ort) + #ORT + '";"' + str(dataset.client_short_telefon) + #TELEFON1 + '";"' + str(dataset.runtime_m) + #FAX1 + '";"' + str(dataset.client_short_mobil) + #Mobil1 + '";"' + str(dataset.cutshortwerbe_feld_selected) + #INTERNET + '";"' + str(dataset.client_short_email) + #EMAIL1 + '";"' + str(dataset.client_short_telefon) + #TELEFON1_AP + '";"' + str(dataset.contract_auto_id) + #KUNDENNR + '";"' + str(dataset.client_short_BANK_computed) + #BANKNAME_1 + '";"' + str(dataset.client_short_KONTO_computed) + #BANKKONTO_1 + '";"' + str(dataset.client_short_BLZ_computed) + #BLZ_1 + '";"' + str(dataset.client_short_IBAN).upper() + #IBAN_1 + '";"' + str(dataset.client_short_BIC).upper() + #BIC_1 + '";"' + str((dataset.client_short_vorname)+' '+str(dataset.client_short_name)+' '+str(dataset.client_short_company))[:20] + #FIBU-KONTENBEZEICHNUNG + '";"' + str(dataset.web_contract_paysum_all).replace('.', ',') + #KREDITLIMIT + '";"' + str('2') + #HAUSBANK + '";"' + str('8') + #FAELLIG_TG + '"\n') + dataset.client_is_export_finance_date = datetime.now() + dataset.client_is_export_finance = True + Text += 'Kunde : '+str(dataset.contract_auto_name)+' - '+str(dataset.client_short_company)+'\n' + #contract.userdef_vars = [(5,0,0)] + _logger.info('Export Kunden CSV : C_'+str(self.id)+' - Senden - Uservar entfernt ') + contract_user_var.var_ID = 'BHEXMARK_1_SENT' + contract_user_var.var_value = str(datetime.now()) + filename = 'KDEXPORT_' + str(datetime.now().strftime("%Y%m%d%H%M%S")) + '_' + str(uuid.uuid4())[:8] + '.csv' + attachment_id = self.env['ir.attachment'].create({ 'name': filename, 'datas': self._encode_csv_attachment(csv)}) + attachment_id.allow_shared_download=True + attachment_id.folder_id=self.env['documents.folders'].search([('name','=','Kundenanlage')],limit=1).id + csvs = self.env['dss.csvs'].create({'csv_id': filename,'csv_name': filename, 'csv_filename': filename, 'csv_attachment_id': attachment_id.id, 'csv_mailtext': Text, 'csv_description': 'Export Kunden CSV am '+str(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))}) + #trigger.SendMail(self,MailEmpfaenger,MailEmpfaengerCC,MailEmpfaengerBCC,MailTemplate,MailSender,has_attachments=False,attachmentlist=[],with_confirm=False,Dataset=None): + base_url = self.env['ir.config_parameter'].get_param('web.base.url') + download_url = '/web/content/' + str(attachment_id.id) + '?download=true' + return { + "type": "ir.actions.act_url", + "url": str(base_url) + str(download_url), + "target": "new" + } def create_cloud_structure_client(self, newClient): _logger.info("Create Cloud Structure for Client "+str(newClient.id)+" - "+str(newClient.cloudlink)) @@ -1253,6 +1669,7 @@ class dsscontracts(models.Model): self.client.email = self.client_short_email self.client.rgemail = self.client_short_rgemail self.client.leitwegid = self.client_short_leitwegid + self.client.dsspartner_Company_type = self.client_short_company_type def pyaction_dss_contract_update_short_from_partner(self): self.client_short_company=self.client.company_name @@ -1266,8 +1683,9 @@ class dsscontracts(models.Model): self.client_short_email=self.client.email self.client_short_rgemail=self.client.rgemail self.client_short_leitwegid=self.client.leitwegid + self.client_short_company_type=self.client.dsspartner_Company_type - + @api.model def buildText(self): _logger.info('Build Text for Contract : C_'+str(self.id)) @@ -1410,7 +1828,11 @@ class dsscontracts(models.Model): #raise ValidationError((all_contract_text)) return contracts - + + + def lm_contract_tool_execute(self): + return self.buildText() + def pyopen_dss_contract_add_media_form_view(self): _logger.info('Open Add Media Form') diff --git a/models/dss_contract_uservar.py b/models/dss_contract_uservar.py old mode 100644 new mode 100755 index ca4b8df..70568da --- a/models/dss_contract_uservar.py +++ b/models/dss_contract_uservar.py @@ -53,6 +53,7 @@ class dsscontractuservars(models.Model): _inherit = ['mail.thread','dss.activity.mixin'] uuid = fields.Char(default=lambda self: self._default_uuid(), required=True, readonly=True, copy=False, string='UUID') var_name = fields.Char(string="Variable Name", tracking=True) + var_ID = fields.Char(string="Variable ID", tracking=True) var_value = fields.Char(string="Variable Wert", tracking=True) var_description = fields.Text(string="Variable Beschreibung",tracking=True) contract = fields.Many2one('dss.contracts', string="Vertrag",required=True, tracking=True) diff --git a/models/dss_contracts_move_field_wizard.py b/models/dss_contracts_move_field_wizard.py new file mode 100755 index 0000000..4334d88 --- /dev/null +++ b/models/dss_contracts_move_field_wizard.py @@ -0,0 +1,76 @@ +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class DssContractsMoveFieldWizard(models.TransientModel): + _name = "dss.contracts.move.field.wizard" + _description = "Vertragsfelder auf Ausweichfeld umsetzen" + + project_id = fields.Many2one( + 'dss.projects', + string='Projekt', + required=True, + readonly=True, + ) + target_field_id = fields.Many2one( + 'dss.advertisefields', + string='Ausweichfeld', + required=True, + domain="[('project','=',project_id), '|', ('contract','=',False), ('isblocked','=',False)]", + ) + + @api.model + def default_get(self, fields_list): + values = super().default_get(fields_list) + active_ids = self.env.context.get('active_ids') or [] + contracts = self.env['dss.contracts'].browse(active_ids).exists() + if contracts: + projects = contracts.mapped('project') + if len(projects) == 1: + values.setdefault('project_id', projects.id) + return values + + def action_move_contracts_to_field(self): + self.ensure_one() + active_ids = self.env.context.get('active_ids') or [] + contracts = self.env['dss.contracts'].browse(active_ids).exists() + if not contracts: + raise ValidationError(_("Bitte mindestens einen Vertrag auswaehlen.")) + + projects = contracts.mapped('project') + if len(projects) != 1: + raise ValidationError(_("Bitte nur Vertraege aus einem Projekt gleichzeitig auswaehlen.")) + if projects.id != self.project_id.id: + raise ValidationError(_("Das gewaehlte Ausweichfeld passt nicht zum Projekt der Vertraege.")) + if self.target_field_id.project.id != self.project_id.id: + raise ValidationError(_("Das Ausweichfeld muss zum gleichen Projekt gehoeren.")) + + contract_ids = contracts.ids + old_fields = contracts.mapped('werbe_feld_selected') + + # Remove selected contracts from all old fields first, so they become available again. + for ad_field in old_fields: + remove_cmds = [(3, contract_id) for contract_id in contract_ids if contract_id in ad_field.contract.ids] + if remove_cmds: + ad_field.write({'contract': remove_cmds}) + if ad_field.contract_save and ad_field.contract_save.id in contract_ids: + ad_field.contract_save = ad_field.contract[:1].id or False + + add_cmds = [(4, contract_id) for contract_id in contract_ids if contract_id not in self.target_field_id.contract.ids] + if add_cmds: + self.target_field_id.write({'contract': add_cmds}) + if not self.target_field_id.contract_save and contract_ids: + self.target_field_id.contract_save = contract_ids[0] + + contracts.write({'werbe_feld_selected': [(6, 0, [self.target_field_id.id])]}) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Ausweichfeld gesetzt'), + 'message': _('%s Vertrag(e) wurden auf das Feld %s umgesetzt.') % (len(contracts), self.target_field_id.feldname), + 'type': 'success', + 'sticky': False, + } + } diff --git a/models/dss_crm_extended.py b/models/dss_crm_extended.py old mode 100644 new mode 100755 diff --git a/models/dss_csvs.py b/models/dss_csvs.py new file mode 100755 index 0000000..2bb8e43 --- /dev/null +++ b/models/dss_csvs.py @@ -0,0 +1,326 @@ +import ast +import datetime +import json +import re +import uuid +import logging +import base64 +import subprocess +import tempfile +import easywebdav +import os +import os.path +import requests +import hashlib +from PIL import Image,ImageDraw,ImageFont + +from odoo import api, fields, models, _ +from odoo import tools +from odoo.exceptions import ValidationError +from datetime import date +from datetime import datetime +from dateutil.relativedelta import relativedelta +from pyffmpeg import FFmpeg +from tuya_iot import TuyaOpenAPI, TUYA_LOGGER +from tuya_connector import TuyaOpenAPI, TUYA_LOGGER +from webdav4.client import Client +from webdav4.fsspec import WebdavFileSystem +import sys + +TUYA_LOGGER.setLevel(logging.DEBUG) +_logger = logging.getLogger(__name__) + + +class dsscsvs(models.Model): + + @api.model + def create(self,vals): + result = super().create(vals) + return result + + _name = "dss.csvs" + _description = "DigitalSignage CSV Dateien" +# _inherit = ['mail.thread','mail.activity.mixin'] + _rec_name = "csv_id" +# _inherit = ['mail.thread', 'mail.activity.mixin'] + + uuid = fields.Char(default=lambda self: self._default_uuid(), required=True, readonly=True, copy=False, string='UUID') + date_create = fields.Date('Erstellungsdatum',default=lambda self: self._default_create_date()) + date_lastedit = fields.Date('Änderungsdatum') + user_create = fields.Char('Erstellungsuser',default=lambda self: self._default_create_user()) + user_lastedit = fields.Char('Änderungsuser') + csv_id = fields.Char(string="CSV ID", required=True, help = "Das ist eine eindeutige ID für diese Dateityp. Sie wird automatisch generiert und sollte nicht manuell geändert werden.", default=lambda self: self._default_csv_id()) + csv_name = fields.Char(string="CSV Name", required=True, help = "Der Name des Dateityp, die Sie hochladen möchten. Dieser Name wird in der Benutzeroberfläche angezeigt.") + csv_binary = fields.Binary(string="CSV Datei", help = "Die eigentliche Datei, die Sie hochladen möchten. Diese Datei wird in der Datenbank und Cloud gespeichert") + csv_filename = fields.Char(string="CSV Dateiname", help = "Der Name der hochgeladenen Datei. Dies wird automatisch aus dem Dateinamen der hochgeladenen Datei abgerufen.",compute="_compute_csv_filename", store=True) + csv_cloud_link = fields.Char(string="Cloud Link", help = "Der Link zur Datei in der Cloud, wenn die Datei in der Cloud gespeichert ist.") + csv_description = fields.Text(string="CSV Beschreibung", help = "Eine Beschreibung der hochgeladenen Datei. Dies kann Informationen über den Inhalt der Datei, den Verwendungszweck oder andere relevante Details enthalten.") + csv_attachment_id = fields.Many2one('ir.attachment', string="CSV Anhang", help = "Der Anhang, der mit dieser CSV-Datei verbunden ist. Dieser Anhang enthält die tatsächliche Datei, die in der Datenbank gespeichert ist.") + csv_mailtext = fields.Text(string="CSV Mailtext", help = "Der Text, der in der E-Mail verwendet wird, wenn diese CSV-Datei per E-Mail versendet wird.") + + + + @api.depends('binary_binary') + @api.onchange('binary_binary') + def _compute_binary_filename(self): + """Compute the binary filename based on the uploaded file.""" + _logger.info("Binary getting Filename - B_"+str(self.id)) + if len(self)>0: + for record in self: + if record.binary_binary: + # Extract the filename from the binary content + extension = 'jpg' # Default extension + if isinstance(self.binary_binary, str): + filedata = base64.b64decode(self.binary_binary.split("base64,")[-1]) + else: + filedata = base64.b64decode(self.binary_binary) + filedataStr = str(filedata) + _logger.info("Binary getting Filename - B_"+str(self.id)+" - ["+str(filedataStr[0:23])+"]") + if filedataStr[0:12] == "b'\\x89PNG\\": + extension = 'png' + elif filedataStr[0:17] == "b'\\xff\\xd8\\xff": + extension = 'jpg' + elif filedataStr[0:8] == "b'GIF89a": + extension = 'gif' + elif filedataStr[0:22] == "b'\\x49\\x49\\x2a\\x00": + extension = 'tif' + elif filedataStr[0:27] == "b'\\x25\\x50\\x44\\x46\\x2d": + extension = 'pdf' + elif filedataStr[0:23] == "b'\\x00\\x00\\x00 ftypisom": + extension = 'mp4' + elif filedataStr[0:22] == "b'\\x00\\x00\\x00\\x18ftyp": + extension = 'mp4' + elif filedataStr[0:12] == "b'\\x42\\x4d": + extension = 'bmp' + record.binary_filename = f"{record.binary_mediatype.filepartname}{record.binary_contract.contract_auto_id}.{extension}" + + @api.model + def _default_uuid(self): + return str(uuid.uuid4()) + + @api.model + def _default_create_date(self): + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + @api.model + def _default_create_user(self): + return self.env.user.name if self.env.user else 'System' + + @api.model + def _default_binary_id(self): + # Generate a unique binary ID based on the current timestamp and a random UUID + return f"Datei_{str(uuid.uuid4())[:8]}" + + @api.onchange('binary_binary') + def _onchange_binary_binary(self): + """Update binary size and type when binary content is changed.""" + _logger.info("New Binary B_"+str(self.id)) + existing_binary = False + if not self._origin.binary_binary: + existing_binary = False + else: + existing_binary = True + + if not self.binary_contract.cloudlink: + raise ValidationError(_("Der Vertrag hat keinen Cloudlink. Bitte überprüfen Sie den Vertrag.")) + cloudpath = str(self.binary_contract.cloudlink) + _logger.info("New Binary checking Cloudlink B_"+str(self.id)+" - "+str(cloudpath)) + cloudpath = cloudpath + '/' + str(self.binary_mediatype.cloudlink) + client = Client("https://cloud.logumedia.de/remote.php/dav/files/OdooDav/", auth=("odooClient@logumedia.de", "lm2020#OdooDav")) + _logger.info("New Binary checking Cloudlink for cont : "+str(cloudpath)) + cloudexists = False + if client.exists(cloudpath): + # Convert self.binary_binary to bytes-like object + if isinstance(self.binary_binary, str): + filedata = base64.b64decode(self.binary_binary.split("base64,")[-1]) + else: + filedata = base64.b64decode(self.binary_binary) + filedataStr = str(filedata) + _logger.info("New Binary Cloudlink exists - Saving File - B_"+str(self.id)+" - "+str(cloudpath)+" - " + str(filedata)+' - ' + filedataStr[0:8]) + try: + decoded_data = filedata + except base64.binascii.Error as e: + if 'Incorrect padding' in str(e): + filedataStr += b'=' * (4 - len(filedataStr) % 4) + decoded_data = base64.b64decode(filedataStr) + if filedataStr[0:10] == "b'\x89PNG": + extension = 'png' + else: + extension = 'jpg' + _logger.info("New Binary Cloudlink exists - Saving File - B_"+str(self.id)+" - "+str(cloudpath)+" - " + str(base64.b64encode(decoded_data))) + ##infile = open('/tmp/'+filename, "w+b") + #infile = tempfile.NamedTemporaryFile(delete=False) + ##infile.write(filedata) + ##client.upload_fileobj(infile, f"{cloudpath}/{filename}") + ##infile.close() + ##infile.delete() # Delete the temporary file after upload + cloudexists = True + else: + _logger.info("New Binary Cloudlink does not exists B_"+str(self.id)+" - "+str(cloudpath)) + raise ValidationError(_("Der Cloudlink im Vertrag ist nicht vorhanden. Bitte überprüfen Sie den Link ggf. anlegen.")) + + if cloudexists: + if self.binary_binary: + # Calculate the size of the binary content + self.binary_size = len(filedata) + # Determine the file type based on the binary content + self.binary_cloud_link = f"{cloudpath}/{self.binary_filename}" + self.binary_issaved = True + else: + self.binary_size = 0 + self.binary_type = '' + self.binary_cloud_link = '' + self.binary_issaved = False + + + def dload(self): + """Download the binary file.""" + if not self.binary_binary: + raise ValidationError(_("No binary file to download.")) + + # Create a temporary file to store the downloaded content + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=self.binary_filename) + temp_file.write(base64.b64decode(self.binary_binary)) + temp_file.close() + + # Return the file for download + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/{self.id}/{self.binary_filename}?download=true&filename={self.binary_filename}', + 'target': 'self', + } + + def dedit(self): + """Edit the binary file.""" + return { + 'type': 'ir.actions.act_window', + 'res_model': 'dss.binaries', + 'view_mode': 'form', + 'res_id': self.id, + 'target': 'new', + } + + def resend(self): + """Resend the binary file to the contract's partner email.""" + if not self.binary_contract: + raise ValidationError(_("No contract associated with this binary file.")) + if not self.binary_contract.project.standortpartner: + raise ValidationError(_("No partner associated with the contract.")) + if not self.binary_contract.project.standortpartner.email: + raise ValidationError(_("The partner does not have an email address.")) + # Send email with the binary file as attachment + + def genstamp(self): + _logger.info('Create Stamps for Binary: B_'+str(self.id)+' '+str(id)) + # Bildparameter definieren + Medium = self.binary_mediatype + if Medium and Medium.can_gen_stamp and self.binary_contract: + _logger.info('Create Stamps for Contract : B_'+str(self.id)+' - Contract: C_'+str(self.binary_contract.id)+'- Medium : M_'+str(Medium.id)+' - '+str(Medium.medianame)) + # Bildgröße und Hintergrundfarbe festlegen + background_color = (255, 255, 255) # Weißer Hintergrund (RGB) + # Neues Bild erstellen + maxh = Medium.maxsize_h + maxw = Medium.maxsize_w + image = Image.new("RGB", (maxw, maxh), background_color) + d = ImageDraw.Draw(image) + Zeilen = Medium.maxsize_h // 6 + zposy = 2 + if self.binary_contract.client: + text = self.binary_contract.contact_company_name + _logger.info('Create Stamps for Contract : B_'+str(self.id)+' - Contract: C_'+str(self.binary_contract.id)+'- Medium : M_'+str(Medium.id)+' - Text1: '+str(text)) + if len(text) > 35: + # Trenne den Text an einem Leerzeichen nahe bei Position 40 + idx = text.rfind(' ', 0, 35) + if idx == -1: + idx = 35 + text2 = text[:idx] + bcount = len(text2) if text2 else 1 + fonts = maxw // (bcount ) *1.8 + if fonts > maxh: + fonts = maxh - 10 + if fonts <=0 : + fonts = 10 + # font = ImageFont.truetype("addons/web/static/fonts/sign/Zeyada-Regular.ttf", fonts) + font = ImageFont.truetype("addons/web/static/fonts/google/Open_Sans/Open_Sans-SemiBold.ttf", fonts) + d.text((maxw // 2, 1*Zeilen), text2, fill="black",align="center", anchor="ms", font=font) # Beispiel: Ein Rechteck + text = text[idx:] + zposy = 2 + if len(text) > 35: + # Trenne den Text an einem Leerzeichen nahe bei Position 40 + idx = text.rfind(' ', 0, 35) + if idx == -1: + idx = 35 + text2 = text[:idx] + # font = ImageFont.truetype("addons/web/static/fonts/sign/Zeyada-Regular.ttf", fonts) + font = ImageFont.truetype("addons/web/static/fonts/google/Open_Sans/Open_Sans-SemiBold.ttf", fonts) + d.text((maxw // 2, zposy*Zeilen), text2, fill="black",align="center", anchor="ms", font=font) # Beispiel: Ein Rechteck + text = text[idx:] + zposy = 3 + else: + bcount = len(text) if text else 1 + fonts = maxw // (bcount ) *1.8 + if fonts > maxh: + fonts = maxh - 10 + if fonts <=0 : + fonts = 10 + # font = ImageFont.truetype("addons/web/static/fonts/sign/Zeyada-Regular.ttf", fonts) + font = ImageFont.truetype("addons/web/static/fonts/google/Open_Sans/Open_Sans-SemiBold.ttf", fonts) + d.text((maxw // 2, zposy*Zeilen), text, fill="black",align="center", anchor="ms", font=font) # Beispiel: Ein Rechteck + text = self.binary_contract.contact_street + bcount = len(text) if text else 1 + Zeilen = maxh // 6 + fonts = maxw // (bcount ) *1.2 + if fonts > maxh: + fonts = maxh - 10 + if fonts <=0 : + fonts = 10 + # font = ImageFont.truetype("addons/web/static/fonts/sign/Zeyada-Regular.ttf", fonts) + font = ImageFont.truetype("addons/web/static/fonts/google/Open_Sans/Open_Sans-Regular.ttf", fonts) + d.text((maxw // 2, 4*Zeilen), text, fill="black",align="center", anchor="ms", font=font) # Beispiel: Ein Rechteck + text = self.binary_contract.contact_zip+' '+self.binary_contract.contact_city + bcount = len(text) if text else 1 + Zeilen = maxh // 6 + fonts = maxw // (bcount ) *1.2 + if fonts > maxh: + fonts = maxh - 10 + if fonts <=0 : + fonts = 10 + # font = ImageFont.truetype("addons/web/static/fonts/sign/Zeyada-Regular.ttf", fonts) + font = ImageFont.truetype("addons/web/static/fonts/google/Open_Sans/Open_Sans-Regular.ttf", fonts) + d.text((maxw // 2, 5*Zeilen), text, fill="black",align="center", anchor="ms", font=font) # Beispiel: Ein Rechteck + # Bild speichern + filename = Medium.filepartname+self.binary_contract.contract_auto_id+".png" + _logger.info('Create Stamps for Contract : B_'+str(self.id)+' - Contract: C_'+str(self.binary_contract.id)+'- Feld : F_'+str(Medium.id)+' - Filename: '+str(filename)) + image.save(filename) + _logger.info("Stamp file created "+str(filename)) + _logger.info("Copy file to Binaries B_"+str(self)) + try: + self.binary_binary = base64.b64encode(open(filename, "rb").read()) + self.binary_filename = filename + self.binary_type = 'image/png' + self.binary_size = os.path.getsize(filename) + self.binary_issaved = False + self.binary_secured_ro = False + self.binary_used_ro = False + finally: + _logger.info("Binary inserted "+str(self)) + cloudpath = self.binary_contract.cloudlink + if not cloudpath: + raise ValidationError(_("Der Vertrag hat keinen Cloudlink. Bitte überprüfen Sie den Vertrag.")) + if not Medium.cloudlink: + raise ValidationError(_("Der Mediatyp hat keinen Cloudlink. Bitte überprüfen Sie den Mediatyp.")) + cloudpath = cloudpath + '/' + str(Medium.cloudlink) + _logger.info("Copy file to Cloud Path : "+str(cloudpath)) + try: + client = Client("https://cloud.logumedia.de/remote.php/dav/files/OdooDav/", auth=("odooClient@logumedia.de", "lm2020#OdooDav")) + try: + #client.mkdir(new_folder) + #_logger.info("Make Cloud Path : "+str(new_folder)) + client.upload_file(filename, f"{cloudpath}/{filename}") + except Exception as e: + _logger.info("Make Cloud Path error : "+str(e)) + finally: + self.binary_issaved = True + _logger.info("File copied to Cloud Path : "+str(cloudpath)) + return True + diff --git a/models/dss_display_templates.py b/models/dss_display_templates.py index 9616d50..b03bd71 100755 --- a/models/dss_display_templates.py +++ b/models/dss_display_templates.py @@ -50,6 +50,7 @@ class dssadvertisefields(models.Model): feldtemplates = fields.Many2many('dss.advertisefields.templates' , string='Vertrag', store=True,tracking=True) fullsize_w = fields.Integer("Auflösung Breite",tracking=True) fullsize_h = fields.Integer("Auflösung Höhe",tracking=True) + adportal_visible = fields.Boolean('Ad Portal sichtbar', default=False, tracking=True) def _default_create_date(self): return str(date.today()) diff --git a/models/dss_kileads.py b/models/dss_kileads.py old mode 100644 new mode 100755 diff --git a/models/dss_kileads_requests.py b/models/dss_kileads_requests.py old mode 100644 new mode 100755 diff --git a/models/dss_maintains.py b/models/dss_maintains.py index 47577be..f1b62e3 100755 --- a/models/dss_maintains.py +++ b/models/dss_maintains.py @@ -54,22 +54,26 @@ class dssmaintaintasks(models.Model): maintain_travel_end = fields.Datetime('Ankunftszeit', tracking=True) maintain_travel_distance = fields.Float('Anreise Entfernung', tracking=True) maintain_travel_duration = fields.Float('Anreise Dauer', tracking=True, compute='_compute_travel_total_time', store=True) + maintain_travel_duration_man = fields.Float('Anreise Dauer', tracking=True, store=True) maintain_travel_duration_text = fields.Char('Anreise Dauer', tracking=True, compute='_compute_travel_total_time_text', store=True) maintain_work_start = fields.Datetime('Arbeitsbeginn', tracking=True) maintain_work_end = fields.Datetime('Arbeitsende', tracking=True) maintain_work_duration = fields.Float('Arbeits Dauer', tracking=True,compute='_compute_work_total_time', store=True) + maintain_work_duration_man = fields.Float('Arbeits Dauer', tracking=True, store=True) maintain_work_duration_text = fields.Char('Arbeits Dauer', tracking=True, compute='_compute_work_total_time_text', store=True) maintain_return_start = fields.Datetime('Rückreisebeginn', tracking=True) maintain_return_end = fields.Datetime('Rückreiseende', tracking=True) maintain_return_duration = fields.Float('Rückreise Dauer', tracking=True,compute='_compute_return_total_time', store=True) + maintain_return_duration_man = fields.Float('Rückreise Dauer', tracking=True, store=True) maintain_return_distance = fields.Float('Rückreise Entfernung', tracking=True) maintain_return_duration_text = fields.Char('Rückreise Dauer', tracking=True, compute='_compute_return_total_time_text', store=True) maintain_total_start = fields.Datetime('Einsatzbeginn', tracking=True,compute='_compute_maintain_total_start', store=True) maintain_total_end = fields.Datetime('Einsatzende', tracking=True, compute='_compute_maintain_total_end', store=True) maintain_total_duration = fields.Float('Einsatz Dauer', tracking=True,compute='_compute_maintain_total_time', store=True) + maintain_total_duration_man = fields.Float('Einsatz Dauer', tracking=True, store=True) maintain_total_duration_text = fields.Char('Einsatz Dauer', tracking=True, compute='_compute_maintain_total_time_text', store=True) maintain_total_duration_money = fields.Float('Einsatz Kosten', tracking=True, compute='_compute_maintain_total_cost', store=True) @@ -94,33 +98,43 @@ class dssmaintaintasks(models.Model): def _compute_travel_total_time_text(self): for record in self: if record.maintain_travel_duration: - hours = int(record.maintain_travel_duration) - minutes = int((record.maintain_travel_duration - hours) * 60) - record.maintain_travel_duration_text = f"{hours} Stunden {minutes} Minuten" + if record.maintain_travel_duration: + hours = int(record.maintain_travel_duration) + minutes = int((record.maintain_travel_duration - hours) * 60) + record.maintain_travel_duration_text = f"{hours} Stunden {minutes} Minuten" + else: + record.maintain_travel_duration_text = "0 Stunden 0 Minuten" else: - record.maintain_travel_duration_text = "0 Stunden 0 Minuten" + record.maintain_travel_duration_text = "" @api.depends('maintain_work_duration') @api.onchange('maintain_work_duration') def _compute_work_total_time_text(self): for record in self: - if record.maintain_work_duration: - hours = int(record.maintain_work_duration) - minutes = int((record.maintain_work_duration - hours) * 60) - record.maintain_work_duration_text = f"{hours} Stunden {minutes} Minuten" + if not record.maintain_work_duration_man: + if record.maintain_work_duration: + hours = int(record.maintain_work_duration) + minutes = int((record.maintain_work_duration - hours) * 60) + record.maintain_work_duration_text = f"{hours} Stunden {minutes} Minuten" + else: + record.maintain_work_duration_text = "0 Stunden 0 Minuten" else: - record.maintain_work_duration_text = "0 Stunden 0 Minuten" + record.maintain_work_duration_text = "" + @api.depends('maintain_return_duration') @api.onchange('maintain_return_duration') def _compute_return_total_time_text(self): for record in self: - if record.maintain_return_duration: - hours = int(record.maintain_return_duration) - minutes = int((record.maintain_return_duration - hours) * 60) - record.maintain_return_duration_text = f"{hours} Stunden {minutes} Minuten" + if not record.maintain_return_duration_man: + if record.maintain_return_duration: + hours = int(record.maintain_return_duration) + minutes = int((record.maintain_return_duration - hours) * 60) + record.maintain_return_duration_text = f"{hours} Stunden {minutes} Minuten" + else: + record.maintain_return_duration_text = "0 Stunden 0 Minuten" else: - record.maintain_return_duration_text = "0 Stunden 0 Minuten" + record.maintain_return_duration_text = "" @api.depends('maintain_total_duration') @api.onchange('maintain_total_duration') @@ -183,15 +197,21 @@ class dssmaintaintasks(models.Model): record.maintain_total_end = False @api.depends('maintain_travel_duration', 'maintain_work_duration', 'maintain_return_duration') - @api.onchange('maintain_travel_duration', 'maintain_work_duration', 'maintain_return_duration') + @api.onchange('maintain_travel_duration', 'maintain_work_duration', 'maintain_return_duration','maintain_travel_duration_man', 'maintain_work_duration_man', 'maintain_return_duration_man') def _compute_maintain_total_time(self): for record in self: total_duration = 0.0 - if record.maintain_travel_duration: + if record.maintain_travel_duration_man: + total_duration += record.maintain_travel_duration_man + elif record.maintain_travel_duration: total_duration += record.maintain_travel_duration - if record.maintain_work_duration: + if record.maintain_work_duration_man: + total_duration += record.maintain_work_duration_man + elif record.maintain_work_duration: total_duration += record.maintain_work_duration - if record.maintain_return_duration: + if record.maintain_return_duration_man: + total_duration += record.maintain_return_duration_man + elif record.maintain_return_duration: total_duration += record.maintain_return_duration record.maintain_total_duration = total_duration diff --git a/models/dss_phonecenter_query.py b/models/dss_phonecenter_query.py new file mode 100755 index 0000000..ac886df --- /dev/null +++ b/models/dss_phonecenter_query.py @@ -0,0 +1,472 @@ +# -*- coding: utf-8 -*- + +from datetime import timedelta + +from odoo import api, fields, models + + +class DssPhonecenterQuery(models.Model): + _name = "dss.phonecenter.query" + _description = "Telefoniecenter Suchanfrage" + _order = "id desc" + + name = fields.Char("Anfrage", required=True) + project_id = fields.Many2one("dss.projects", string="Projekt", required=True, index=True) + user_id = fields.Many2one("res.users", string="Benutzer", required=True, default=lambda self: self.env.user) + location_query = fields.Char("Ort", required=True) + radius_km = fields.Integer("Umkreis (km)", default=20) + industry_query = fields.Char("Branche", required=True) + state = fields.Selection([ + ("draft", "Neu"), + ("active", "Aktiv"), + ("done", "Abgeschlossen"), + ], default="draft", string="Status") + search_count = fields.Integer("Suchen", default=0) + line_ids = fields.One2many("dss.phonecenter.query.line", "query_id", string="Ergebnisse") + open_line_count = fields.Integer("Offene Eintraege", compute="_compute_line_counts") + done_line_count = fields.Integer("Abarbeitet", compute="_compute_line_counts") + + @api.depends("line_ids.processed", "line_ids.rejected", "line_ids.reschedule_at") + def _compute_line_counts(self): + now = fields.Datetime.now() + for rec in self: + total = len(rec.line_ids) + done = len(rec.line_ids.filtered( + lambda line: line.processed or line.rejected or ( + line.reschedule_at and line.reschedule_at > now and not line.rejected and not line.processed + ) + )) + rec.done_line_count = done + rec.open_line_count = max(0, total - done) + + @api.model + def get_project_queries(self, project_id): + project_id = int(project_id or 0) + if not project_id: + return [] + + records = self.search([ + ("project_id", "=", project_id), + ("user_id", "=", self.env.user.id), + ], order="id desc", limit=100) + + payload = [] + for rec in records: + payload.append({ + "id": rec.id, + "name": rec.name, + "location_query": rec.location_query or "", + "radius_km": rec.radius_km or 20, + "industry_query": rec.industry_query or "", + "state": rec.state, + "search_count": rec.search_count, + "open_line_count": rec.open_line_count, + "done_line_count": rec.done_line_count, + "can_repeat": (rec.open_line_count == 0), + }) + + now = fields.Datetime.now() + reschedule_lines = self.env["dss.phonecenter.query.line"].search([ + ("query_id.project_id", "=", project_id), + ("query_id.user_id", "=", self.env.user.id), + ("reschedule_at", "!=", False), + ("rejected", "=", False), + ("processed", "=", False), + ]) + if reschedule_lines: + due_count = len(reschedule_lines.filtered(lambda l: l.reschedule_at and l.reschedule_at <= now)) + total_count = len(reschedule_lines) + payload.append({ + "id": "reschedule:%s" % project_id, + "name": "Wiedervorlagen", + "location_query": "", + "radius_km": 0, + "industry_query": "", + "state": "active", + "search_count": 0, + "open_line_count": due_count, + "done_line_count": max(0, total_count - due_count), + "can_repeat": True, + "is_reschedule_bucket": True, + }) + return payload + + @api.model + def get_reschedule_details(self, project_id): + project_id = int(project_id or 0) + if not project_id: + return {"ok": False, "message": "Projekt fehlt."} + + now = fields.Datetime.now() + lines = self.env["dss.phonecenter.query.line"].search([ + ("query_id.project_id", "=", project_id), + ("query_id.user_id", "=", self.env.user.id), + ("reschedule_at", "!=", False), + ("rejected", "=", False), + ("processed", "=", False), + ], order="reschedule_at asc, id desc") + + due_lines = lines.filtered(lambda l: l.reschedule_at and l.reschedule_at <= now) + companies = [] + for line in due_lines: + companies.append({ + "id": str(line.external_id or line.id), + "line_id": line.id, + "name": line.name, + "address": line.address or "", + "city": line.city or "", + "website": line.website or "", + "phone": line.phone or "", + "email": line.email or "", + "rating": line.rating or 0, + "source": line.source or "", + "verified": bool(line.verified), + "verified_at": line.verified_at or "", + "place_id": line.place_id or "", + "google_maps_url": line.google_maps_url or "", + "lat": line.lat, + "lng": line.lng, + "types": line.types_json or [], + "processed": bool(line.processed), + "rejected": bool(line.rejected), + "reschedule_at": fields.Datetime.to_string(line.reschedule_at) if line.reschedule_at else "", + "reschedule_note": line.reschedule_note or "", + }) + + return { + "ok": True, + "query": { + "id": "reschedule:%s" % project_id, + "name": "Wiedervorlagen", + "location_query": "", + "radius_km": 0, + "industry_query": "", + "state": "active", + "search_count": 0, + "open_line_count": len(companies), + "done_line_count": max(0, len(lines) - len(companies)), + "can_repeat": True, + "is_reschedule_bucket": True, + }, + "companies": companies, + } + + @api.model + def create_query(self, payload): + payload = payload or {} + project_id = int(payload.get("project_id") or 0) + if not project_id: + return {"ok": False, "message": "Projekt fehlt."} + + location_query = (payload.get("location_query") or "").strip() + industry_query = (payload.get("industry_query") or "").strip() + radius_km = int(payload.get("radius_km") or 20) + + if not location_query or not industry_query: + return {"ok": False, "message": "Ort und Branche sind erforderlich."} + + record = self.create({ + "project_id": project_id, + "user_id": self.env.user.id, + "location_query": location_query, + "industry_query": industry_query, + "radius_km": radius_km, + "name": "%s | %s | %s km" % (industry_query, location_query, radius_km), + "state": "active", + }) + return { + "ok": True, + "query": { + "id": record.id, + "name": record.name, + "location_query": record.location_query, + "radius_km": record.radius_km, + "industry_query": record.industry_query, + "state": record.state, + "search_count": record.search_count, + "open_line_count": record.open_line_count, + "done_line_count": record.done_line_count, + "can_repeat": (record.open_line_count == 0), + }, + } + + @api.model + def get_query_details(self, query_id): + query_id = int(query_id or 0) + record = self.browse(query_id) + if not record.exists() or record.user_id.id != self.env.user.id: + return {"ok": False, "message": "Anfrage nicht gefunden."} + + now = fields.Datetime.now() + companies = [] + visible_lines = record.line_ids.filtered( + lambda l: not l.rejected and not ( + l.reschedule_at and l.reschedule_at > now and not l.processed + ) + ) + for line in visible_lines.sorted(key=lambda l: (l.processed, -l.id)): + companies.append({ + "id": str(line.external_id or line.id), + "line_id": line.id, + "name": line.name, + "address": line.address or "", + "city": line.city or "", + "website": line.website or "", + "phone": line.phone or "", + "email": line.email or "", + "rating": line.rating or 0, + "source": line.source or "", + "verified": bool(line.verified), + "verified_at": line.verified_at or "", + "place_id": line.place_id or "", + "google_maps_url": line.google_maps_url or "", + "lat": line.lat, + "lng": line.lng, + "types": line.types_json or [], + "processed": bool(line.processed), + "rejected": bool(line.rejected), + "reschedule_at": fields.Datetime.to_string(line.reschedule_at) if line.reschedule_at else "", + "reschedule_note": line.reschedule_note or "", + }) + + return { + "ok": True, + "query": { + "id": record.id, + "name": record.name, + "location_query": record.location_query or "", + "radius_km": record.radius_km or 20, + "industry_query": record.industry_query or "", + "state": record.state, + "search_count": record.search_count, + "open_line_count": record.open_line_count, + "done_line_count": record.done_line_count, + "can_repeat": (record.open_line_count == 0), + }, + "companies": companies, + } + + @api.model + def can_repeat_query(self, query_id): + query_id = int(query_id or 0) + record = self.browse(query_id) + if not record.exists() or record.user_id.id != self.env.user.id: + return {"ok": False, "can_repeat": False} + return { + "ok": True, + "can_repeat": bool(record.open_line_count == 0), + "open_line_count": record.open_line_count, + } + + @api.model + def save_query_results(self, query_id, companies): + query_id = int(query_id or 0) + record = self.browse(query_id) + if not record.exists() or record.user_id.id != self.env.user.id: + return {"ok": False, "message": "Anfrage nicht gefunden."} + + if not isinstance(companies, list): + companies = [] + + line_model = self.env["dss.phonecenter.query.line"] + added = 0 + for company in companies: + if not isinstance(company, dict): + continue + name = (company.get("name") or "").strip() + if not name: + continue + + external_id = str(company.get("id") or company.get("place_id") or name) + existing = line_model.search([ + ("query_id", "=", record.id), + ("external_id", "=", external_id), + ], limit=1) + + vals = { + "query_id": record.id, + "external_id": external_id, + "place_id": (company.get("place_id") or "").strip(), + "name": name, + "address": (company.get("address") or "").strip(), + "city": (company.get("city") or "").strip(), + "website": (company.get("website") or "").strip(), + "phone": (company.get("phone") or "").strip(), + "email": (company.get("email") or "").strip(), + "rating": company.get("rating") or 0, + "source": (company.get("source") or "").strip(), + "verified": bool(company.get("verified", False)), + "verified_at": (company.get("verified_at") or "").strip(), + "google_maps_url": (company.get("google_maps_url") or "").strip(), + "lat": company.get("lat"), + "lng": company.get("lng"), + "types_json": company.get("types") if isinstance(company.get("types"), list) else [], + "processed": bool(existing.processed) if existing else False, + "rejected": bool(existing.rejected) if existing else False, + "reject_reason": existing.reject_reason if existing else "", + "decision_summary": existing.decision_summary if existing else "", + "reschedule_at": existing.reschedule_at if existing else False, + "reschedule_note": existing.reschedule_note if existing else "", + } + + if existing: + existing.write(vals) + else: + line_model.create(vals) + added += 1 + + record.search_count = (record.search_count or 0) + 1 + record.state = "active" + + details = self.get_query_details(record.id) + details.update({"ok": True, "added": added}) + return details + + @api.model + def mark_result_processed(self, line_id, processed=True): + line_id = int(line_id or 0) + line = self.env["dss.phonecenter.query.line"].browse(line_id) + if not line.exists() or line.query_id.user_id.id != self.env.user.id: + return {"ok": False, "message": "Eintrag nicht gefunden."} + + line.processed = bool(processed) + if processed: + line.rejected = False + line.reschedule_at = False + line.reschedule_note = "" + if line.query_id.open_line_count == 0: + line.query_id.state = "done" + else: + line.query_id.state = "active" + + return { + "ok": True, + "query_id": line.query_id.id, + "open_line_count": line.query_id.open_line_count, + "can_repeat": (line.query_id.open_line_count == 0), + } + + @api.model + def set_reschedule(self, line_id, reschedule_at=False, days=0, note=""): + line_id = int(line_id or 0) + line = self.env["dss.phonecenter.query.line"].browse(line_id) + if not line.exists() or line.query_id.user_id.id != self.env.user.id: + return {"ok": False, "message": "Eintrag nicht gefunden."} + + target_dt = False + if reschedule_at: + target_dt = fields.Datetime.to_datetime(reschedule_at) + if not target_dt: + day_count = int(days or 0) + if day_count > 0: + target_dt = fields.Datetime.now() + timedelta(days=day_count) + + if not target_dt: + return {"ok": False, "message": "Bitte Wiedervorlagezeit angeben."} + + line.write({ + "last_decision": "reschedule", + "processed": False, + "rejected": False, + "reschedule_at": target_dt, + "reschedule_note": (note or "").strip(), + }) + + if line.query_id.open_line_count == 0: + line.query_id.state = "done" + else: + line.query_id.state = "active" + + return { + "ok": True, + "line_id": line.id, + "query_id": line.query_id.id, + "reschedule_at": fields.Datetime.to_string(line.reschedule_at) if line.reschedule_at else "", + "open_line_count": line.query_id.open_line_count, + } + + @api.model + def apply_contact_decision(self, line_id, decision, reason="", summary=""): + line_id = int(line_id or 0) + line = self.env["dss.phonecenter.query.line"].browse(line_id) + if not line.exists() or line.query_id.user_id.id != self.env.user.id: + return {"ok": False, "message": "Eintrag nicht gefunden."} + + decision_value = (decision or "").strip() + # Backward-compatible alias: treat no_interest as a rejection/discard decision. + if decision_value == "no_interest": + decision_value = "discard" + vals = { + "last_decision": decision_value, + "reject_reason": (reason or "").strip(), + "decision_summary": (summary or "").strip(), + } + if decision_value == "discard": + vals.update({ + "rejected": True, + "processed": True, + "reschedule_at": False, + "reschedule_note": "", + }) + elif decision_value in ("research", "reschedule", "keep"): + vals.update({ + "rejected": False, + "processed": False, + }) + if decision_value in ("research", "keep"): + vals.update({ + "reschedule_at": False, + "reschedule_note": "", + }) + + line.write(vals) + if line.query_id.open_line_count == 0: + line.query_id.state = "done" + else: + line.query_id.state = "active" + + return { + "ok": True, + "line_id": line.id, + "query_id": line.query_id.id, + "decision": decision_value, + "rejected": bool(line.rejected), + "open_line_count": line.query_id.open_line_count, + } + + +class DssPhonecenterQueryLine(models.Model): + _name = "dss.phonecenter.query.line" + _description = "Telefoniecenter Suchergebnis" + _order = "id desc" + + query_id = fields.Many2one("dss.phonecenter.query", string="Anfrage", required=True, ondelete="cascade", index=True) + external_id = fields.Char("Externe ID", index=True) + place_id = fields.Char("Place ID") + name = fields.Char("Unternehmen", required=True) + address = fields.Char("Adresse") + city = fields.Char("Ort") + website = fields.Char("Webseite") + phone = fields.Char("Telefon") + email = fields.Char("Email") + rating = fields.Float("Rating") + source = fields.Char("Quelle") + verified = fields.Boolean("Verifiziert") + verified_at = fields.Char("Verifiziert am") + google_maps_url = fields.Char("Google Maps URL") + lat = fields.Float("Breitengrad") + lng = fields.Float("Laengengrad") + types_json = fields.Json("Typen") + processed = fields.Boolean("Abarbeitet", default=False) + rejected = fields.Boolean("Abgelehnt", default=False) + reject_reason = fields.Text("Ablehnungsgrund") + decision_summary = fields.Text("Entscheidungszusammenfassung") + last_decision = fields.Selection([ + ("reschedule", "Wiedervorlage"), + ("discard", "Komplett verwerfen"), + ("no_interest", "Kein Interesse"), + ("research", "Weitere Recherchen"), + ("keep", "Zunaechst behalten"), + ], string="Letzte Entscheidung") + reschedule_at = fields.Datetime("Wiedervorlage am") + reschedule_note = fields.Text("Wiedervorlage Notiz") diff --git a/models/dss_portal_user_profile.py b/models/dss_portal_user_profile.py new file mode 100755 index 0000000..b771946 --- /dev/null +++ b/models/dss_portal_user_profile.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models + + +class DssPortalUserProfile(models.Model): + _name = 'dss.portal.user.profile' + _description = 'AD Portal User Profile' + + user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + + ranking_enabled = fields.Boolean('Im Ranking', default=False) + ranking_position = fields.Integer('Ranking Position', default=0) + ranking_sum = fields.Float('Ranking Summe', default=0.0) + + right_contract_overview = fields.Boolean('Portal Recht: Vertragsanzeige', default=True) + right_contract_detail = fields.Boolean('Portal Recht: Vertragsdetails', default=True) + right_media_view = fields.Boolean('Portal Recht: Medienanzeige', default=True) + right_ranking_view = fields.Boolean('Portal Recht: Rankinganzeige', default=True) + right_graphic_examples = fields.Boolean('Portal Recht: Grafikbeispiele anzeigen/erstellen', default=False) + + _sql_constraints = [ + ('dss_portal_user_profile_user_unique', 'unique(user_id)', 'Fuer jeden Benutzer ist nur ein Portal-Profil erlaubt.'), + ] diff --git a/models/dss_projects.py b/models/dss_projects.py index 9ca68e1..62167fb 100755 --- a/models/dss_projects.py +++ b/models/dss_projects.py @@ -78,6 +78,7 @@ class dssprojects(models.Model): grundsystemicon_different = fields.Boolean('Grundsystem Icon anders',default=False,tracking=True) grundsystemicon_different_image = fields.Binary('Anderes Icon',tracking=True) grundsystem_webinput_template = fields.Char(related='grundsystemname.web_input_template',tracking=True) + grundsystem_webinput_showimage_url = fields.Char(related='grundsystemname.web_input_showimage_url',tracking=True) btntemplate = fields.Many2one('dss.display.templates',string='Buttonsvorlage',tracking=True) @@ -168,9 +169,45 @@ class dssprojects(models.Model): lastkdnr = fields.Integer('Letzte Temp. KdNr', tracking=True) lastrealkdnr = fields.Integer('Letzte Real. KdNr', tracking=True) reservedkdnr = fields.Char('Reservierte KdNr', tracking=True) + lostkdnr = fields.Char('Verlorene KdNr "," Seperated', tracking=True) web_input_template = fields.Char('Web-Eingabe Template', tracking=True) + project_binaries = fields.Many2many('dss.binaries', string='Projektdateien', tracking=True) + contract_finish_img_cloud = fields.Char('Überg. Bildlink',tracking=True) + + def pyopen_dss_project_add_binary_form_view(self): + _logger.info('Open Add Binary Form') + view = self.env.ref("DigitalSignage.dss_project_binaries_view_form_wizard") + _logger.info('Open Wizzard Binary Form '+str(view)) + return { + 'type': 'ir.actions.act_window', + 'view_mode': 'form' , + 'view_type': 'form' , + 'view_id': view.id, + 'res_model': 'dss.binaries.wizard.new' , + 'target': 'new' , + 'views':[(view.id,'form')], + 'context': {'default_binary_project':self.id}, + } + + def pyopen_dss_project_add_binary_form_view_from_wizzard(self,media): + _logger.info('Open Add Binary Form') + view = self.env.ref("DigitalSignage.dss_binaries_view_form") + binary = self.env['dss.binaries'].create({'binary_project':self.id,'binary_name':self.projektname,'binary_mediatype':media.id,'binary_id':str(media.filepartname+'_'+str(self.projectid)),'binary_binary':media.standard_image}) + self.project_binaries = [(4, binary.id)] + _logger.info('Open Add Binary Form '+str(view)+' B_'+str(binary)) + return { + 'type': 'ir.actions.act_window', + 'view_mode': 'form' , + 'view_type': 'form' , + 'view_id': view.id, + 'res_model': 'dss.binaries' , + 'target': 'new' , + 'views':[(view.id,'form')], + 'res_id':binary.id, + } + @api.model def _read_group_system_ids(self, stages, domain, order): project_system_ids = self.env['dss.systemtypen'].search([('open_close_standard_state','=',False)], order='order') diff --git a/models/dss_res_users.py b/models/dss_res_users.py index d61ceb0..6ad1dc9 100755 --- a/models/dss_res_users.py +++ b/models/dss_res_users.py @@ -1,11 +1,6 @@ # -*- coding: utf-8 -*- -import uuid -import logging - -from odoo import api,fields, models - -_logger = logging.getLogger(__name__) +from odoo import api, fields, models class Resusers(models.Model): @@ -14,5 +9,111 @@ class Resusers(models.Model): # company_type = fields.Selection(selection_add=[('dss_client','Werbekunde'),('dss_partner','Partnerunternehmen')]) dss_projects = fields.Many2many('dss.projects',string='DigitalSignage Projekte') + phonecenter_view_mode = fields.Selection([ + ('callcenter', 'Callcenter'), + ('projectmanagement', 'Projektmanagement'), + ('aussendienst', 'Aussendienst'), + ('administrator', 'Administrator'), + ], string='Ansichtsmodus', default='callcenter') + portal_ranking_enabled = fields.Boolean('Im Ranking', compute='_compute_portal_profile_flags', inverse='_inverse_portal_ranking_enabled') + portal_ranking_position = fields.Integer('Ranking Position', compute='_compute_portal_profile_flags', inverse='_inverse_portal_ranking_position') + portal_ranking_sum = fields.Float('Ranking Summe', compute='_compute_portal_profile_flags', inverse='_inverse_portal_ranking_sum') + + portal_right_contract_overview = fields.Boolean('Portal Recht: Vertragsanzeige', compute='_compute_portal_profile_flags', inverse='_inverse_portal_right_contract_overview') + portal_right_contract_detail = fields.Boolean('Portal Recht: Vertragsdetails', compute='_compute_portal_profile_flags', inverse='_inverse_portal_right_contract_detail') + portal_right_media_view = fields.Boolean('Portal Recht: Medienanzeige', compute='_compute_portal_profile_flags', inverse='_inverse_portal_right_media_view') + portal_right_ranking_view = fields.Boolean('Portal Recht: Rankinganzeige', compute='_compute_portal_profile_flags', inverse='_inverse_portal_right_ranking_view') + portal_right_graphic_examples = fields.Boolean('Portal Recht: Grafikbeispiele anzeigen/erstellen', compute='_compute_portal_profile_flags', inverse='_inverse_portal_right_graphic_examples') + show_graphic_examples = fields.Boolean('Grafikbeispiele anzeigen/erstellen', compute='_compute_portal_profile_flags', inverse='_inverse_show_graphic_examples') + + def _get_or_create_portal_profile(self): + self.ensure_one() + profile_model = self.env['dss.portal.user.profile'].sudo() + profile = profile_model.search([('user_id', '=', self.id)], limit=1) + if profile: + return profile + return profile_model.create({ + 'user_id': self.id, + 'ranking_enabled': False, + 'ranking_position': 0, + 'ranking_sum': 0.0, + 'right_contract_overview': True, + 'right_contract_detail': True, + 'right_media_view': True, + 'right_ranking_view': True, + 'right_graphic_examples': False, + }) + + def _set_portal_profile_value(self, field_name, value): + for user in self: + profile = user._get_or_create_portal_profile() + profile.write({field_name: value}) + + def _compute_portal_profile_flags(self): + profile_model = self.env['dss.portal.user.profile'].sudo() + profiles = profile_model.search([('user_id', 'in', self.ids)]) + profile_by_user = {profile.user_id.id: profile for profile in profiles} + + for user in self: + profile = profile_by_user.get(user.id) + user.portal_ranking_enabled = bool(profile.ranking_enabled) if profile else False + user.portal_ranking_position = int(profile.ranking_position or 0) if profile else 0 + user.portal_ranking_sum = float(profile.ranking_sum or 0.0) if profile else 0.0 + + user.portal_right_contract_overview = bool(profile.right_contract_overview) if profile else True + user.portal_right_contract_detail = bool(profile.right_contract_detail) if profile else True + user.portal_right_media_view = bool(profile.right_media_view) if profile else True + user.portal_right_ranking_view = bool(profile.right_ranking_view) if profile else True + user.portal_right_graphic_examples = bool(profile.right_graphic_examples) if profile else False + user.show_graphic_examples = bool(profile.right_graphic_examples) if profile else False + + def _inverse_portal_ranking_enabled(self): + for user in self: + user._set_portal_profile_value('ranking_enabled', bool(user.portal_ranking_enabled)) + + def _inverse_portal_ranking_position(self): + for user in self: + user._set_portal_profile_value('ranking_position', int(user.portal_ranking_position or 0)) + + def _inverse_portal_ranking_sum(self): + for user in self: + user._set_portal_profile_value('ranking_sum', float(user.portal_ranking_sum or 0.0)) + + def _inverse_portal_right_contract_overview(self): + for user in self: + user._set_portal_profile_value('right_contract_overview', bool(user.portal_right_contract_overview)) + + def _inverse_portal_right_contract_detail(self): + for user in self: + user._set_portal_profile_value('right_contract_detail', bool(user.portal_right_contract_detail)) + + def _inverse_portal_right_media_view(self): + for user in self: + user._set_portal_profile_value('right_media_view', bool(user.portal_right_media_view)) + + def _inverse_portal_right_ranking_view(self): + for user in self: + user._set_portal_profile_value('right_ranking_view', bool(user.portal_right_ranking_view)) + + def _inverse_portal_right_graphic_examples(self): + for user in self: + user._set_portal_profile_value('right_graphic_examples', bool(user.portal_right_graphic_examples)) + + def _inverse_show_graphic_examples(self): + for user in self: + user._set_portal_profile_value('right_graphic_examples', bool(user.show_graphic_examples)) + + @api.model + def get_phone_center_profile(self): + user = self.env.user + mode = user.phonecenter_view_mode or 'callcenter' + can_see_all_projects = mode in ('projectmanagement', 'administrator') + return { + 'user_id': user.id, + 'view_mode': mode, + 'can_see_all_projects': can_see_all_projects, + 'assigned_project_ids': user.dss_projects.ids, + } + # dss_uuid = fields.Char('uuid') diff --git a/models/dss_settings.py b/models/dss_settings.py index 0746f67..14197f4 100755 --- a/models/dss_settings.py +++ b/models/dss_settings.py @@ -10,6 +10,7 @@ import tempfile import easywebdav import os import os.path +import requests from odoo import api, fields, models, _ @@ -74,6 +75,26 @@ class dssSettings(models.Model): show_expand_contract_group_ad_state = fields.Boolean('Jeden Kampagnen-Status anzeigen ?',help='im Groupierungsmodus der Verträge nach Kampagnenstatus alle (auch unbenutzte) Statusspalten anzeigen ?',tracking=True) show_expand_contract_group_ad_work_state = fields.Boolean('Jeden Kampagnen-Arbeitsstatus anzeigen ?',help='im Groupierungsmodus der Verträge nach Kampagnen-Arbeitsstatus alle (auch unbenutzte) Statusspalten anzeigen ?',tracking=True) show_expand_contract_group_ad_todo_state = fields.Boolean('Jeden Kampagnen-Aufgabenschritt anzeigen ?',help='im Groupierungsmodus der Verträge nach Kampagnen-Aufgabenschritt alle (auch unbenutzte) Aufgabenspalten anzeigen ?',tracking=True) + adportal_ranking_show_foreign_names = fields.Boolean('Ranking Fremdnamen anzeigen', default=True, help='Wenn deaktiviert, werden im AD Portal Ranking fremde Namen als ***** angezeigt. Der eigene Name bleibt sichtbar.', tracking=True) + adportal_graphics_base_prompt = fields.Text('AD Portal Grafik Prompt', compute='_compute_adportal_graphics_config', inverse='_inverse_adportal_graphics_config', tracking=True) + adportal_graphics_use_chatgpt = fields.Boolean('AD Portal: Benutze ChatGPT', compute='_compute_adportal_graphics_config', inverse='_inverse_adportal_graphics_config', tracking=True) + adportal_graphics_chatgpt_model = fields.Selection( + [ + ('gpt-image-1', 'gpt-image-1'), + ('dall-e-3', 'dall-e-3'), + ('dall-e-2', 'dall-e-2'), + ], + string='AD Portal ChatGPT Modell', + compute='_compute_adportal_graphics_config', + inverse='_inverse_adportal_graphics_config', + tracking=True, + ) + adportal_graphics_api_url = fields.Char('AD Portal Grafik API URL', compute='_compute_adportal_graphics_config', inverse='_inverse_adportal_graphics_config', tracking=True) + adportal_graphics_api_key = fields.Char('AD Portal Grafik API Key', compute='_compute_adportal_graphics_config', inverse='_inverse_adportal_graphics_config', tracking=True) + adportal_graphics_api_timeout = fields.Integer('AD Portal Grafik API Timeout (Sekunden)', compute='_compute_adportal_graphics_config', inverse='_inverse_adportal_graphics_config', tracking=True) + adportal_google_company_check = fields.Boolean('AD Portal Firmenpruefung via Google', compute='_compute_adportal_graphics_config', inverse='_inverse_adportal_graphics_config', tracking=True) + adportal_google_api_key = fields.Char('AD Portal Google API Key', compute='_compute_adportal_graphics_config', inverse='_inverse_adportal_graphics_config', tracking=True) + adportal_google_cse_id = fields.Char('AD Portal Google CSE ID', compute='_compute_adportal_graphics_config', inverse='_inverse_adportal_graphics_config', tracking=True) freigabe_auto_time = fields.Integer('Auto Freigabetage',help='Automatische Freigabe nach x Tagen',tracking=True) tv_vertragsankuendigung_actionID = fields.Integer('TV Vertragsankündigung ActionID',help="Aktion die ausgeführt wird, wenn im Web-Vertragseingang der Button Vertragsankündigung geklickt wird.",tracking=True) systemonline_color = fields.Char('Farbe Online',tracking=True) @@ -84,11 +105,126 @@ class dssSettings(models.Model): rporturl = fields.Char('RPort URL',tracking=True) rportusername = fields.Char('RPort Username',tracking=True) rporttoken = fields.Char('RPort API Token',tracking=True) + ai_agent_enabled = fields.Boolean('AI Agent aktiv', default=True, tracking=True) + ai_agent_base_url = fields.Char('AI Base URL', default='https://api.openai.com/v1', tracking=True) + ai_agent_model = fields.Char('AI Modell', default='gpt-4o-mini', tracking=True) + ai_agent_api_key = fields.Char('AI API Key', tracking=True) + ai_agent_system_prompt = fields.Text( + 'AI System Prompt', + default='Du bist ein hilfreicher virtueller Mitarbeiter im Odoo Backend.', + tracking=True, + ) + ai_agent_callback_prompt = fields.Text( + 'AI Callback Prompt', + default=( + 'Verwende Callback-Funktionen fuer Datenzugriffe. ' + 'Nutze get_model_records fuer gezielte Modell/Feld-Abfragen, ' + 'get_all_contracts fuer Vertragslisten, get_contract_by_number fuer Vertragsdetails ' + 'und get_projects fuer Projektlisten. Nutze create_model_record nur fuer explizite ' + 'Anlagevorgaenge und nur mit freigegebenen Schreibfeldern. Formuliere Antworten kompakt ' + 'und nachvollziehbar.' + ), + tracking=True, + ) + ai_agent_callback_models = fields.Char( + 'AI Callback Modelle', + default='dss.contracts,dss.projects', + help='Kommagetrennte Modellnamen, die per get_model_records abgefragt werden duerfen.', + tracking=True, + ) + ai_agent_callback_fields_json = fields.Text( + 'AI Callback Felder (JSON)', + default='{"dss.contracts": ["contract_auto_id", "contract_name", "client_short_company", "contract_state", "project", "vertragssumme"], "dss.projects": ["projectid", "projektname", "name", "standort_strasse", "standort_plz", "standort_ort", "maps_visitors", "aktstatus"]}', + help='JSON-Objekt mit erlaubten Feldern pro Modell fuer get_model_records.', + tracking=True, + ) + ai_agent_callback_aliases_json = fields.Text( + 'AI Callback Feld-Aliase (JSON)', + default='{"dss.contracts": {"kundennummer": "contract_auto_id", "vertragsnummer": "contract_auto_id", "vertragsname": "contract_name", "vertragskennung": "contract_auto_name", "kunde": "client_short_company", "firma": "client_short_company", "vorname": "client_short_vorname", "nachname": "client_short_name", "autoverlaengerung": "contract_auto_extend", "auto_verlaengerung": "contract_auto_extend", "verlaengerung_auto": "contract_auto_extend"}}', + help='JSON-Objekt mit Aliasen pro Modell, z.B. {"dss.contracts": {"kundennummer": "contract_auto_id"}}.', + tracking=True, + ) + ai_agent_callback_write_fields = fields.Text( + 'AI Callback Schreibfelder', + default='', + help='Exakte Erlaubnisliste fuer Schreibzugriffe im Muster model.field, z.B. dss.projects.name oder dss.contracts.remark. Mehrere Eintraege per Komma oder Zeilenumbruch trennen.', + tracking=True, + ) + ai_agent_timeout_seconds = fields.Integer('AI Timeout (Sekunden)', default=60, tracking=True) + ai_agent_temperature = fields.Float('AI Temperatur', default=0.3, tracking=True) + ai_agent_max_context_messages = fields.Integer('AI Kontext Nachrichten', default=20, tracking=True) + ai_agent_rag_enabled = fields.Boolean('AI RAG aktiv (LangChain)', default=True, tracking=True) + ai_agent_rag_top_k = fields.Integer('AI RAG Treffer (Top-K)', default=8, tracking=True) + ai_agent_contracts_access = fields.Boolean('AI Zugriff auf Vertraege', default=True, tracking=True) + ai_agent_contracts_limit = fields.Integer('AI Vertragskontext Anzahl (0 = alle)', default=0, tracking=True) + ai_agent_projects_access = fields.Boolean('AI Zugriff auf Projekte', default=True, tracking=True) + ai_agent_projects_limit = fields.Integer('AI Projektkontext Anzahl (0 = alle)', default=0, tracking=True) + phonecenter_company_provider = fields.Selection([ + ('google_places', 'Google Places'), + ('custom_api', 'Custom Firmen-API'), + ('mock', 'Mock/Fallback'), + ], string='Telefoniecenter Firmenprovider', default='google_places', tracking=True) + phonecenter_company_api_url = fields.Char('Telefoniecenter Firmen-API URL', tracking=True) + phonecenter_company_api_key = fields.Char('Telefoniecenter Firmen-API Key', tracking=True) + phonecenter_company_timeout = fields.Integer('Telefoniecenter Firmen-Timeout (Sekunden)', default=20, tracking=True) + phonecenter_ai_provider = fields.Selection([ + ('none', 'Keine KI'), + ('openai', 'OpenAI kompatibel'), + ('custom_api', 'Custom KI-API'), + ], string='Telefoniecenter KI-Provider', default='openai', tracking=True) + phonecenter_ai_api_url = fields.Char('Telefoniecenter KI-API URL', tracking=True) + phonecenter_ai_api_key = fields.Char('Telefoniecenter KI-API Key', tracking=True) + phonecenter_ai_model = fields.Char('Telefoniecenter KI Modell', default='gpt-4o-mini', tracking=True) + phonecenter_search_min_entries = fields.Integer('Telefoniecenter Min. Suchergebnisse', default=8, tracking=True) + phonecenter_search_max_results = fields.Integer('Telefoniecenter Max. Ergebnisse', default=30, tracking=True) + phonecenter_search_cache_minutes = fields.Integer('Telefoniecenter Cache (Minuten)', default=30, tracking=True) showdebug = fields.Boolean('Debug',help='Debug Informationen aktivieren ',tracking=True) - m2mcron = fields.Many2one('ir.cron',string='M2M Cron',tracking=True) + m2mcron = fields.Many2one('ir.cron',string='M2M Cron',tracking=True) variables = fields.Many2many('dss.settings.vars',string='Einstellungs Variablen',tracking=True) + def _as_bool(self, value): + return str(value or '').strip().lower() in ('1', 'true', 'yes', 'y', 'on') + + def _normalize_adportal_graphics_chatgpt_model(self, value): + allowed_models = {'gpt-image-1', 'dall-e-3', 'dall-e-2'} + normalized_value = (value or '').strip() + return normalized_value if normalized_value in allowed_models else 'gpt-image-1' + + def _compute_adportal_graphics_config(self): + config = self.env['ir.config_parameter'].sudo() + for record in self: + record.adportal_graphics_base_prompt = config.get_param( + 'digitalsignage.adportal_graphics_base_prompt', + default='Erstelle eine hochwertige Werbegrafik im Corporate-Stil fuer die Firma.', + ) + record.adportal_graphics_use_chatgpt = self._as_bool(config.get_param('digitalsignage.adportal_graphics_use_chatgpt', default='False')) + record.adportal_graphics_chatgpt_model = self._normalize_adportal_graphics_chatgpt_model( + config.get_param('digitalsignage.adportal_graphics_chatgpt_model', default='gpt-image-1') + ) + record.adportal_graphics_api_url = config.get_param('digitalsignage.adportal_graphics_api_url', default='') + record.adportal_graphics_api_key = config.get_param('digitalsignage.adportal_graphics_api_key', default='') + record.adportal_graphics_api_timeout = int(config.get_param('digitalsignage.adportal_graphics_api_timeout', default='90') or 90) + record.adportal_google_company_check = self._as_bool(config.get_param('digitalsignage.adportal_google_company_check', default='False')) + record.adportal_google_api_key = config.get_param('digitalsignage.adportal_google_api_key', default='') + record.adportal_google_cse_id = config.get_param('digitalsignage.adportal_google_cse_id', default='') + + def _inverse_adportal_graphics_config(self): + config = self.env['ir.config_parameter'].sudo() + for record in self: + config.set_param('digitalsignage.adportal_graphics_base_prompt', record.adportal_graphics_base_prompt or '') + config.set_param('digitalsignage.adportal_graphics_use_chatgpt', 'True' if record.adportal_graphics_use_chatgpt else 'False') + config.set_param( + 'digitalsignage.adportal_graphics_chatgpt_model', + self._normalize_adportal_graphics_chatgpt_model(record.adportal_graphics_chatgpt_model), + ) + config.set_param('digitalsignage.adportal_graphics_api_url', record.adportal_graphics_api_url or '') + config.set_param('digitalsignage.adportal_graphics_api_key', record.adportal_graphics_api_key or '') + config.set_param('digitalsignage.adportal_graphics_api_timeout', int(record.adportal_graphics_api_timeout or 90)) + config.set_param('digitalsignage.adportal_google_company_check', 'True' if record.adportal_google_company_check else 'False') + config.set_param('digitalsignage.adportal_google_api_key', record.adportal_google_api_key or '') + config.set_param('digitalsignage.adportal_google_cse_id', record.adportal_google_cse_id or '') + def _get_settingvalue(self,valuename): settings = (self.env['dss.settings'].search([],limit=1)) wert = settings._origin.read([valuename])[0][valuename] @@ -230,6 +366,652 @@ class dssSettings(models.Model): records.append(fieldrecord) return records + @api.model + def _phonecenter_get_settings(self): + settings = self.search([], limit=1) + if not settings: + return {} + + ai_api_url = settings.phonecenter_ai_api_url or settings.ai_agent_base_url or 'https://api.openai.com/v1' + ai_api_key = settings.phonecenter_ai_api_key or settings.ai_agent_api_key or '' + ai_model = settings.phonecenter_ai_model or settings.ai_agent_model or 'gpt-4o-mini' + + return { + 'google_maps_key': settings.google_maps_key or '', + 'company_provider': settings.phonecenter_company_provider or 'google_places', + 'company_api_url': settings.phonecenter_company_api_url or '', + 'company_api_key': settings.phonecenter_company_api_key or '', + 'company_timeout': max(5, int(settings.phonecenter_company_timeout or 20)), + 'ai_provider': settings.phonecenter_ai_provider or 'openai', + 'ai_api_url': ai_api_url, + 'ai_api_key': ai_api_key, + 'ai_model': ai_model, + 'min_entries': max(1, int(settings.phonecenter_search_min_entries or 8)), + 'max_results': max(5, int(settings.phonecenter_search_max_results or 30)), + } + + @api.model + def phonecenter_get_search_config(self): + cfg = self._phonecenter_get_settings() + return { + 'company_provider': cfg.get('company_provider', 'google_places'), + 'ai_provider': cfg.get('ai_provider', 'openai'), + 'min_entries': cfg.get('min_entries', 8), + 'max_results': cfg.get('max_results', 30), + 'radius_max': 2000, + } + + @api.model + def phonecenter_check_location(self, query): + query = (query or '').strip() + if not query: + return {'found': False, 'message': 'Kein Ort angegeben.'} + + cfg = self._phonecenter_get_settings() + google_key = cfg.get('google_maps_key') + + if google_key: + try: + response = requests.get( + 'https://maps.googleapis.com/maps/api/geocode/json', + params={ + 'address': query, + 'key': google_key, + 'language': 'de', + }, + timeout=cfg.get('company_timeout', 20), + ) + payload = response.json() + if payload.get('status') == 'OK' and payload.get('results'): + first = payload['results'][0] + location = first.get('geometry', {}).get('location', {}) + return { + 'found': True, + 'provider': 'google_geocode', + 'query': query, + 'lat': location.get('lat'), + 'lng': location.get('lng'), + 'address': first.get('formatted_address') or query, + } + except Exception as ex: + _logger.warning('Phonecenter location check via Google failed: %s', ex) + + try: + response = requests.get( + 'https://nominatim.openstreetmap.org/search', + params={ + 'q': query, + 'format': 'json', + 'limit': 1, + 'addressdetails': 1, + }, + headers={ + 'User-Agent': 'DigitalSignagePhoneCenter/1.0', + }, + timeout=cfg.get('company_timeout', 20), + ) + payload = response.json() + if isinstance(payload, list) and payload: + first = payload[0] + return { + 'found': True, + 'provider': 'nominatim', + 'query': query, + 'lat': float(first.get('lat') or 0.0), + 'lng': float(first.get('lon') or 0.0), + 'address': first.get('display_name') or query, + } + except Exception as ex: + _logger.warning('Phonecenter location check via Nominatim failed: %s', ex) + + return {'found': False, 'query': query, 'message': 'Ort nicht gefunden.'} + + @api.model + def _phonecenter_normalize_company_item(self, item): + if not isinstance(item, dict): + return None + name = (item.get('name') or '').strip() + if not name: + return None + return { + 'id': str(item.get('id') or item.get('place_id') or item.get('external_id') or name), + 'place_id': (item.get('place_id') or '').strip(), + 'name': name, + 'address': (item.get('address') or item.get('formatted_address') or '').strip(), + 'city': (item.get('city') or '').strip(), + 'website': (item.get('website') or '').strip(), + 'phone': (item.get('phone') or '').strip(), + 'email': (item.get('email') or '').strip(), + 'rating': item.get('rating') or 0, + 'source': (item.get('source') or '').strip(), + 'google_maps_url': (item.get('google_maps_url') or '').strip(), + 'types': item.get('types') or [], + 'lat': item.get('lat'), + 'lng': item.get('lng'), + 'verified': bool(item.get('verified', False)), + 'verified_at': item.get('verified_at') or '', + } + + @api.model + def _phonecenter_analyze_company(self, company, cfg): + company = company or {} + name = company.get('name') or '' + website = company.get('website') or '' + address = company.get('address') or '' + phone = company.get('phone') or '' + rating = company.get('rating') or '' + + fallback = 'Firma: %s | Telefon: %s | Webseite: %s | Adresse: %s | Rating: %s' % ( + name or '-', + phone or '-', + website or '-', + address or '-', + rating or '-', + ) + + ai_provider = (cfg.get('ai_provider') or 'openai').strip() + if ai_provider not in ('openai', 'custom_api'): + return fallback + + api_key = cfg.get('ai_api_key') or '' + api_url = (cfg.get('ai_api_url') or '').strip() + if not api_url: + if ai_provider == 'openai': + api_url = 'https://api.openai.com/v1' + else: + return fallback + if not api_key: + return fallback + + try: + if ai_provider == 'openai': + response = requests.post( + '%s/chat/completions' % api_url.rstrip('/'), + headers={ + 'Authorization': 'Bearer %s' % api_key, + 'Content-Type': 'application/json', + }, + json={ + 'model': cfg.get('ai_model') or 'gpt-4o-mini', + 'temperature': 0.2, + 'messages': [ + {'role': 'system', 'content': 'Du analysierst Firmenkontakte fuer ein deutsches Callcenter. Antworte kurz in maximal 4 Saetzen.'}, + {'role': 'user', 'content': json.dumps({ + 'task': 'Analysiere den Firmenkontakt fuer Erstansprache, nenne Nutzenargument, Risiko und naechsten Schritt.', + 'company': company, + }, ensure_ascii=False)}, + ], + }, + timeout=cfg.get('company_timeout', 20), + ) + payload = response.json() if response else {} + content = (((payload.get('choices') or [{}])[0].get('message') or {}).get('content') or '').strip() + return content or fallback + + response = requests.post( + api_url, + headers={ + 'Authorization': 'Bearer %s' % api_key, + 'Content-Type': 'application/json', + }, + json={ + 'task': 'company_analysis', + 'company': company, + }, + timeout=cfg.get('company_timeout', 20), + ) + payload = response.json() if response else {} + if isinstance(payload, dict): + return (payload.get('analysis') or payload.get('summary') or '').strip() or fallback + return fallback + except Exception as ex: + _logger.warning('Phonecenter company analysis failed: %s', ex) + return fallback + + @api.model + def phonecenter_review_bad_contact(self, payload): + payload = payload or {} + cfg = self._phonecenter_get_settings() + + reason = (payload.get('reason') or '').strip() + contact = payload.get('contact') or {} + note = (payload.get('note') or '').strip() + if not isinstance(contact, dict): + contact = {} + + if not reason: + return { + 'ok': False, + 'message': 'Bitte Grund fuer die Einschaetzung angeben.', + } + + fallback_summary = ( + 'Einschaetzung: %s. Kontakt: %s (%s). Telefon: %s. Nächster Schritt: Wiedervorlage oder weitere Recherche pruefen.' + % ( + reason, + contact.get('company') or '-', + contact.get('city') or '-', + contact.get('phone') or '-', + ) + ) + fallback_actions = ['reschedule', 'research', 'keep', 'discard'] + + ai_provider = (cfg.get('ai_provider') or 'openai').strip() + api_key = cfg.get('ai_api_key') or '' + api_url = (cfg.get('ai_api_url') or '').strip() + model = cfg.get('ai_model') or 'gpt-4o-mini' + + if ai_provider == 'none' or not api_key: + return { + 'ok': True, + 'summary': fallback_summary, + 'actions': fallback_actions, + 'source': 'fallback', + } + + if not api_url and ai_provider == 'openai': + api_url = 'https://api.openai.com/v1' + + try: + if ai_provider == 'openai': + response = requests.post( + '%s/chat/completions' % api_url.rstrip('/'), + headers={ + 'Authorization': 'Bearer %s' % api_key, + 'Content-Type': 'application/json', + }, + json={ + 'model': model, + 'temperature': 0.2, + 'messages': [ + { + 'role': 'system', + 'content': ( + 'Du analysierst schlechte Callcenter-Kontakte. ' + 'Antworte nur als JSON mit keys "summary" und "actions". ' + 'actions muss eine Teilmenge aus ["reschedule", "discard", "research", "keep"] sein.' + ), + }, + { + 'role': 'user', + 'content': json.dumps({ + 'reason': reason, + 'contact': contact, + 'classification_note': note, + 'task': 'Erzeuge eine kurze Zusammenfassung und passende naechste Schritte.', + }, ensure_ascii=False), + }, + ], + 'response_format': {'type': 'json_object'}, + }, + timeout=cfg.get('company_timeout', 20), + ) + data = response.json() if response else {} + content = (((data.get('choices') or [{}])[0].get('message') or {}).get('content') or '').strip() + parsed = json.loads(content) if content else {} + actions = parsed.get('actions') if isinstance(parsed, dict) else [] + if not isinstance(actions, list): + actions = [] + cleaned = [a for a in actions if a in fallback_actions] + return { + 'ok': True, + 'summary': (parsed.get('summary') if isinstance(parsed, dict) else '') or fallback_summary, + 'actions': cleaned or fallback_actions, + 'source': 'llm', + } + + if ai_provider == 'custom_api' and api_url: + response = requests.post( + api_url, + headers={ + 'Authorization': 'Bearer %s' % api_key, + 'Content-Type': 'application/json', + }, + json={ + 'task': 'bad_contact_review', + 'reason': reason, + 'contact': contact, + 'classification_note': note, + }, + timeout=cfg.get('company_timeout', 20), + ) + data = response.json() if response else {} + actions = data.get('actions', []) if isinstance(data, dict) else [] + if not isinstance(actions, list): + actions = [] + cleaned = [a for a in actions if a in fallback_actions] + return { + 'ok': True, + 'summary': (data.get('summary') if isinstance(data, dict) else '') or fallback_summary, + 'actions': cleaned or fallback_actions, + 'source': 'custom_api', + } + except Exception as ex: + _logger.warning('Phonecenter bad contact review failed: %s', ex) + + return { + 'ok': True, + 'summary': fallback_summary, + 'actions': fallback_actions, + 'source': 'fallback', + } + + @api.model + def _phonecenter_dedupe_companies(self, items): + deduped = [] + seen = set() + for raw in items or []: + item = self._phonecenter_normalize_company_item(raw) + if not item: + continue + key = (item.get('name', '').lower().strip(), item.get('address', '').lower().strip()) + if key in seen: + continue + seen.add(key) + deduped.append(item) + return deduped + + @api.model + def _phonecenter_search_google_places(self, industry, lat, lng, radius_km, cfg): + key = cfg.get('company_api_key') or cfg.get('google_maps_key') + if not key: + return [] + + query = (industry or '').strip() or 'unternehmen' + radius_m = max(1000, int((radius_km or 10) * 1000)) + params = { + 'query': query, + 'location': f'{lat},{lng}', + 'radius': radius_m, + 'key': key, + 'language': 'de', + } + items = [] + + try: + response = requests.get( + 'https://maps.googleapis.com/maps/api/place/textsearch/json', + params=params, + timeout=cfg.get('company_timeout', 20), + ) + payload = response.json() + for row in payload.get('results', [])[:cfg.get('max_results', 30)]: + location = row.get('geometry', {}).get('location', {}) + items.append({ + 'id': row.get('place_id'), + 'place_id': row.get('place_id'), + 'name': row.get('name'), + 'address': row.get('formatted_address') or row.get('vicinity') or '', + 'rating': row.get('rating') or 0, + 'city': '', + 'source': 'google_places', + 'lat': location.get('lat'), + 'lng': location.get('lng'), + }) + except Exception as ex: + _logger.warning('Phonecenter Google Places search failed: %s', ex) + + return items + + @api.model + def _phonecenter_search_custom_company_api(self, industry, lat, lng, radius_km, cfg): + api_url = (cfg.get('company_api_url') or '').strip() + if not api_url: + return [] + + headers = {'Content-Type': 'application/json'} + if cfg.get('company_api_key'): + headers['Authorization'] = 'Bearer %s' % cfg.get('company_api_key') + + body = { + 'industry': industry, + 'lat': lat, + 'lng': lng, + 'radius_km': radius_km, + 'limit': cfg.get('max_results', 30), + } + + try: + response = requests.post(api_url, headers=headers, json=body, timeout=cfg.get('company_timeout', 20)) + payload = response.json() + if isinstance(payload, dict): + return payload.get('companies', []) or [] + except Exception as ex: + _logger.warning('Phonecenter custom company API search failed: %s', ex) + return [] + + @api.model + def _phonecenter_enrich_companies_openai(self, companies, industry, location_query, cfg): + api_url = (cfg.get('ai_api_url') or '').rstrip('/') + api_key = cfg.get('ai_api_key') or '' + model = cfg.get('ai_model') or 'gpt-4o-mini' + if not api_url or not api_key: + return companies + + prompt = { + 'industry': industry, + 'location': location_query, + 'companies': companies, + 'task': 'Normalisiere, entferne Dubletten und bewerte Relevanz fuer Branchen- und Ortsbezug. Antworte als JSON-Objekt mit key "companies".', + } + + try: + response = requests.post( + '%s/chat/completions' % api_url, + headers={ + 'Authorization': 'Bearer %s' % api_key, + 'Content-Type': 'application/json', + }, + json={ + 'model': model, + 'temperature': 0.1, + 'messages': [ + {'role': 'system', 'content': 'Du bist ein Daten-Normalisierer fuer Firmenlisten. Gib nur gueltiges JSON zurueck.'}, + {'role': 'user', 'content': json.dumps(prompt, ensure_ascii=False)}, + ], + 'response_format': {'type': 'json_object'}, + }, + timeout=cfg.get('company_timeout', 20), + ) + data = response.json() + content = (((data.get('choices') or [{}])[0].get('message') or {}).get('content') or '').strip() + parsed = json.loads(content) if content else {} + enriched = parsed.get('companies', []) if isinstance(parsed, dict) else [] + return self._phonecenter_dedupe_companies(enriched) or companies + except Exception as ex: + _logger.warning('Phonecenter OpenAI enrichment failed: %s', ex) + return companies + + @api.model + def _phonecenter_enrich_companies_custom(self, companies, industry, location_query, cfg): + api_url = (cfg.get('ai_api_url') or '').strip() + if not api_url: + return companies + + headers = {'Content-Type': 'application/json'} + if cfg.get('ai_api_key'): + headers['Authorization'] = 'Bearer %s' % cfg.get('ai_api_key') + + body = { + 'industry': industry, + 'location': location_query, + 'companies': companies, + } + + try: + response = requests.post(api_url, headers=headers, json=body, timeout=cfg.get('company_timeout', 20)) + payload = response.json() + if isinstance(payload, dict): + enriched = payload.get('companies', []) or [] + normalized = self._phonecenter_dedupe_companies(enriched) + return normalized or companies + except Exception as ex: + _logger.warning('Phonecenter custom AI enrichment failed: %s', ex) + return companies + + @api.model + def _phonecenter_get_google_place_details(self, place_id, cfg): + key = cfg.get('company_api_key') or cfg.get('google_maps_key') + if not key or not place_id: + return {} + + detail_fields = ','.join([ + 'name', + 'formatted_address', + 'formatted_phone_number', + 'international_phone_number', + 'website', + 'rating', + 'user_ratings_total', + 'business_status', + 'types', + 'url', + 'geometry', + ]) + try: + response = requests.get( + 'https://maps.googleapis.com/maps/api/place/details/json', + params={ + 'place_id': place_id, + 'fields': detail_fields, + 'key': key, + 'language': 'de', + }, + timeout=cfg.get('company_timeout', 20), + ) + payload = response.json() + if payload.get('status') != 'OK': + return {} + details = payload.get('result') or {} + return { + 'id': place_id, + 'place_id': place_id, + 'name': details.get('name') or '', + 'address': details.get('formatted_address') or '', + 'phone': details.get('formatted_phone_number') or details.get('international_phone_number') or '', + 'website': details.get('website') or '', + 'email': '', + 'rating': details.get('rating') or 0, + 'source': 'google_places', + 'google_maps_url': details.get('url') or '', + 'types': details.get('types') or [], + 'verified': True, + 'verified_at': fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'business_status': details.get('business_status') or '', + 'lat': (details.get('geometry') or {}).get('location', {}).get('lat'), + 'lng': (details.get('geometry') or {}).get('location', {}).get('lng'), + } + except Exception as ex: + _logger.warning('Phonecenter Google Place details failed: %s', ex) + return {} + + @api.model + def phonecenter_get_company_details(self, payload): + payload = payload or {} + cfg = self._phonecenter_get_settings() + company = payload.get('company') or {} + if not isinstance(company, dict): + company = {} + + provider = (company.get('source') or cfg.get('company_provider') or 'google_places').strip() + place_id = (company.get('place_id') or company.get('id') or '').strip() + + details = {} + if provider == 'google_places' and place_id: + details = self._phonecenter_get_google_place_details(place_id, cfg) + + if not details: + details = self._phonecenter_normalize_company_item(company) or {} + if details: + details['verified'] = bool(details.get('verified', False)) + details['verified_at'] = details.get('verified_at') or '' + + if not details: + return { + 'ok': False, + 'message': 'Keine Unternehmensdetails gefunden.', + } + + details.setdefault('source', provider) + details['analysis'] = self._phonecenter_analyze_company(details, cfg) + return { + 'ok': True, + 'company': details, + } + + @api.model + def phonecenter_search_companies(self, payload): + payload = payload or {} + cfg = self._phonecenter_get_settings() + + current_options = payload.get('current_options') or [] + current_list = self._phonecenter_dedupe_companies(current_options) + + min_entries = cfg.get('min_entries', 8) + if current_list and len(current_list) < min_entries: + return { + 'companies': current_list, + 'added_count': 0, + 'search_blocked': True, + 'min_entries': min_entries, + 'message': 'Naechste Suche erst ab Mindestanzahl moeglich.', + } + + industry = (payload.get('industry') or '').strip() + location_query = (payload.get('location_query') or '').strip() + radius_km = int(payload.get('radius_km') or 20) + + lat = payload.get('lat') + lng = payload.get('lng') + if (lat is None or lng is None) and location_query: + location_info = self.phonecenter_check_location(location_query) + lat = location_info.get('lat') + lng = location_info.get('lng') + + if lat is None or lng is None: + return { + 'companies': current_list, + 'added_count': 0, + 'search_blocked': bool(current_list and len(current_list) < min_entries), + 'min_entries': min_entries, + 'message': 'Kein gueltiger Ort fuer die Suche vorhanden.', + } + + provider = cfg.get('company_provider', 'google_places') + found = [] + if provider == 'google_places': + found = self._phonecenter_search_google_places(industry, lat, lng, radius_km, cfg) + elif provider == 'custom_api': + found = self._phonecenter_search_custom_company_api(industry, lat, lng, radius_km, cfg) + + if not found: + found = [{ + 'id': 'fallback-%s-1' % (industry or 'unternehmen'), + 'name': '%s %s 1' % (industry or 'Unternehmen', location_query or ''), + 'city': location_query, + 'source': 'mock', + }] + + normalized_new = self._phonecenter_dedupe_companies(found) + ai_provider = cfg.get('ai_provider', 'openai') + if ai_provider == 'openai': + normalized_new = self._phonecenter_enrich_companies_openai(normalized_new, industry, location_query, cfg) + elif ai_provider == 'custom_api': + normalized_new = self._phonecenter_enrich_companies_custom(normalized_new, industry, location_query, cfg) + + combined = self._phonecenter_dedupe_companies(current_list + normalized_new) + combined = combined[:cfg.get('max_results', 30)] + + return { + 'companies': combined, + 'added_count': max(0, len(combined) - len(current_list)), + 'search_blocked': bool(combined and len(combined) < min_entries), + 'min_entries': min_entries, + 'radius_km': radius_km, + 'provider': provider, + 'ai_provider': ai_provider, + } + class dssSettingsSave(models.Model): diff --git a/models/dss_systemtypen.py b/models/dss_systemtypen.py index ec4f5f8..f1c188b 100755 --- a/models/dss_systemtypen.py +++ b/models/dss_systemtypen.py @@ -55,6 +55,7 @@ class dsssystemtypen(models.Model): showonlinestate_type = fields.Selection([('RPORT','RPort Status'),('VNNOX','Vnnox System')]) inputform_type = fields.Char('Eingabeformular Typ', tracking=True) web_input_template = fields.Char('Web-Eingabe Template', tracking=True) + web_input_showimage_url = fields.Char('Web-Eingabe Bild URL', tracking=True) diff --git a/models/dss_trigger.py b/models/dss_trigger.py index 4d10f81..3bffb28 100755 --- a/models/dss_trigger.py +++ b/models/dss_trigger.py @@ -20,7 +20,7 @@ TUYA_LOGGER.setLevel(logging.ERROR) logging.setLoggerClass(OdooCustomLogger) _logger = logging.getLogger(__name__) -#_logger.setloging(False) +_logger.setloging(False) ##_logger = logging.getLogger(__name__) @@ -291,7 +291,7 @@ class trigger(models.Model): if mailcc: _logger.info('Trigger Aktion TRA_' + str(self)+' Sending Email - vor CC Add deep') VsPartner = self.env['res.partner'].search([('id','=',Dataset.contract_writer.id)]) - ccmailto = ccmailto+';'+VsPartner.email + ccmailto = ccmailto+', '+VsPartner.email _logger.info('Trigger Aktion TRA_' + str(self)+' Sending Email CC Writer - FIX Empfänger : '+str(VsPartner)+' -> '+str(ccmailto)+' + '+str(VsPartner.email)) _logger.info('Trigger Aktion TRA_' + str(self)+' Sending Email - Template : '+str(email_template)+" Empfänger :"+str(mailto)+'/'+str(ccmailto)+' -> '+str(Dataset)) email_template.email_to = mailto @@ -327,7 +327,7 @@ class trigger(models.Model): return True - + class dsstriggerconditions(models.Model): _name = "dss.triggerconditions" _description = "DigitalSignage Trigger Bedingungen" @@ -752,6 +752,7 @@ class dsstriggertypes(models.Model): trigger_block_config = fields.Char('Blockly Design',tracking=True) trigger_block_raw_config = fields.Char('Blockly command Design',tracking=True) trigger_block_command_uptodate = fields.Boolean('Blockly command aktuell',tracking=True) + trigger_auto_code_create = fields.Boolean('Autom. Cod erstellen',tracking=True, default=False) trigger_block_command = fields.Char('Blockly command',tracking=True) @api.model @@ -860,11 +861,11 @@ class dsstriggertypes(models.Model): @api.model def NewBlockTrigger(self,triggername,triggeraktiv): _logger.info("Trigger New Block Trigger !"+str(triggername)) - newtrigger = self.env['dss.triggertypes'].create({'triggername':triggername,'trigger_is_block_config':True,'trigger_active':triggeraktiv}) + newtrigger = self.env['dss.triggertypes'].create({'triggername':triggername,'trigger_is_block_config':True,'trigger_active':triggeraktiv,'trigger_auto_code_create':False}) return newtrigger @api.model - def UpdateBlockTrigger(self,triggername,blockcode,blockcommand,blockcommand_uptodate,triggertable,triggeraktiv ): + def UpdateBlockTrigger(self,triggername,blockcode,blockcommand,blockcommand_uptodate,triggertable,triggeraktiv,triggerautocode=False): _logger.info("Trigger Update Block Trigger !"+str(triggername)) foundtrigger = self.env['dss.triggertypes'].search([('triggername','=',triggername)]) if foundtrigger: @@ -875,6 +876,7 @@ class dsstriggertypes(models.Model): foundtrigger.trigger_block_command_uptodate=blockcommand_uptodate foundtrigger.trigger_block_command=blockcommand foundtrigger.trigger_table = triggertable_ + foundtrigger.trigger_auto_code_create = bool(triggerautocode) return foundtrigger @api.model @@ -939,7 +941,10 @@ class dsstriggeractions(models.Model): email_has_attachments = fields.Boolean('Email mit Anhang',tracking=True,help='Soll die Email mit Anhang versendet werden ?') email_contract_attachment_types = fields.Many2many('dss.mediatypes',tracking=True,help='Welche Dateitypen aus dem Vertrag sollen als Anhang versendet werden ?') + + activity_user_fix = fields.Boolean('Empfänger der Aufgabe ist fix',tracking=True) activity_user = fields.Many2one('res.users','Empfänger der Aufgabe',tracking=True) + activity_user_field = fields.Many2one('ir.model.fields', string='Empfänger der Aufgabe',tracking=True) activity_type = fields.Many2one('mail.activity.type','Art der Aufgabe',tracking=True) activity_date_type = fields.Selection([('TOPLU','Von heute in x Tagen'),('TOFIX','An einem festen Datum'),('TOFIXDB','An einem festen Datum der Datenbank'),('TOPLUDB','An einem festen Datum der Datenbank + x Tage')],tracking=True) activity_date_type_add_days = fields.Integer('x Tage Aufschlag',tracking=True) @@ -1010,7 +1015,7 @@ class dsstriggeractions(models.Model): if self.email_getfrom == "FIX": mailto = "" for partner in self.email_fix_email: - mailto = mailto+';'+partner.email + mailto = mailto+','+partner.email _logger.info('Trigger Aktion TRA_' + str(self)+' Sending Email - FIX Empfänger : '+str(self.email_getfrom)+' -> '+str(mailto)) elif self.email_getfrom == "DATA": mailto = str(Dataset[self.email_data_field.name]) @@ -1090,8 +1095,11 @@ class dsstriggeractions(models.Model): elif self.activity_date_type == 'TOFIXDB': deadline = Dataset[self.action_field.name] elif self.activity_date_type == 'TOPLUDB': - deadline = Dataset[self.action_field.name] - deadline = deadline + relativedelta(days=self.activity_date_type_add_days) + base_deadline = Dataset[self.action_field.name] + if not base_deadline: + # Fallback avoids bool + relativedelta crash when source field is empty. + base_deadline = datetime.now() + deadline = base_deadline + relativedelta(days=self.activity_date_type_add_days) if not Dataset: _logger.info('Trigger Aktion TRA_' + str(self)+' Create Task - No Dataset - use Test '+str(self.trigger_table)+' '+str(self.action_test_id)) @@ -1102,11 +1110,26 @@ class dsstriggeractions(models.Model): _logger.info('Trigger Aktion TRA_' + str(self)+' Create Task - No Model '+str(ds)+' '+str(self.trigger_table)) else: _logger.info('Trigger Aktion TRA_' + str(self)+' Create Task '+str(self.Info_Subject)+' ('+str(self.activity_user)+') - Model : '+str(self.trigger_table.id)+'/'+str(ds.id)+' '+str(deadline)) + if self.activity_user_fix: + _logger.info('Trigger Aktion TRA_' + str(self)+' Create Task '+str(self.Info_Subject)+' - User FIX ('+str(self.activity_user)+')') + userid = self.activity_user.id + else: + _logger.info('Trigger Aktion TRA_' + str(self)+' Create Task '+str(self.Info_Subject)+' - User current ('+str(self.env.user.name)+') from field '+str(self.activity_user_field.name)+' -> '+str(Dataset[self.activity_user_field.name])) + partner = self.env['res.partner'].search([('id','=',Dataset[self.activity_user_field.name].id)],limit=1) + _logger.info('Trigger Aktion TRA_' + str(self)+' Create Task '+str(self.Info_Subject)+' - User current ('+str(self.env.user.name)+') from field '+str(self.activity_user_field.name)+' -> '+str(Dataset[self.activity_user_field.name])+' -> Partner : '+str(partner)) + if partner.user_ids: + if len(partner.user_ids) > 1: + userid = partner.user_ids[0].id + else: + userid = partner.user_ids.id + else: + userid = self.env.user.id + _logger.info('Trigger Aktion TRA_' + str(self)+' Create Task '+str(self.Info_Subject)+' - User current ('+str(self.env.user.name)+') from field '+str(self.activity_user_field.name)+' -> '+str(Dataset[self.activity_user_field.name])+' -> Partner : '+str(partner)+' -> User : '+str(userid)) self.env['mail.activity'].create({ 'display_name': self.Info_Subject, 'summary': self.activity_text, 'date_deadline': deadline, - 'user_id': self.activity_user.id, + 'user_id': userid, 'res_id': ds.id, 'res_model_id': self.trigger_table.id, 'activity_type_id': self.activity_type.id @@ -1167,11 +1190,11 @@ class dsstriggermodel(models.Model): return obj @api.model - def trigger_track_template(self, changes, Dataset): + def trigger_track_template(self, changes, Dataset): _logger.info("Trigger in track_template - "+str(Dataset)+" - Changes : "+str(changes)) hook = self.env['dss.settings.vars'].search([('varname','=','N8NWebhook-OnChange')]) - if hook: - if hook.varvalue: + if (hook): + if not (hook.varvalue == "FALSE"): _logger.info("Trigger in track_template - N8NWebhook-OnChange - "+str(Dataset.triggergroup)+" - Changes : "+str(changes)) # Call N8N Webhook try: diff --git a/models/utils.py b/models/utils.py old mode 100644 new mode 100755 diff --git a/security/groups.xml b/security/groups.xml index 54cfb2f..8b35fd7 100755 --- a/security/groups.xml +++ b/security/groups.xml @@ -4,6 +4,11 @@ 15 + + Telefoniecenter + 16 + + Standarduser @@ -35,4 +40,34 @@ + + Grundrecht Telefoniecenter + + + + + + Ansichtsmodus: Callcenter + + + + + + Ansichtsmodus: Projektmanagement + + + + + + Ansichtsmodus: Aussendienst + + + + + + Ansichtsmodus: Administrator + + + + \ No newline at end of file diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv index 14f4902..1c69917 100755 --- a/security/ir.model.access.csv +++ b/security/ir.model.access.csv @@ -33,6 +33,7 @@ digitalsignage_dss_triggergroups_group_user,access.dss.triggergroups,model_dss_t digitalsignage_dss_maintaintask_group_user,access.dss.maintaintask,model_dss_maintaintask,base.group_user,1,1,1,1 digitalsignage_dss_provision_group_user,access.dss.provision,model_dss_provision,base.group_user,1,1,1,1 digitalsignage_dss_provisionstypen_group_user,access.dss.provisionstypen,model_dss_provisionstypen,DigitalSignage.group_prov_user,1,1,1,1 +digitalsignage_dss_company_type_group_user,access.dss.company.type,model_dss_company_type,base.group_user,1,1,1,1 digitalsignage_dss_payintervals_group_user,access.dss.payintervals,model_dss_payintervals,base.group_user,1,1,1,1 digitalsignage_dss_importinvoicelist_group_user,access.dss.importinvoicelist,model_dss_importinvoicelist,base.group_user,1,1,1,1 digitalsignage_dss_web_contracts_group_user,access.dss_web_contracts,model_dss_web_contracts,base.group_user,1,1,1,1 @@ -53,8 +54,17 @@ digitalsignage_dss_triggeractions_groups_group_user,access.dss.triggeractions.gr digitalsignage_dss_screendesign_group_user,access.dss.screendesign,model_dss_screendesign,base.group_user,1,1,1,1 digitalsignage_dss_binaries_group_user,access.dss.binaries,model_dss_binaries,base.group_user,1,1,1,1 digitalsignage_dss_binaries_wizard_new_group_user,access.dss.binaries.wizard.new,model_dss_binaries_wizard_new,base.group_user,1,1,1,1 +digitalsignage_dss_advertisefields_create_from_template_wizard_group_user,access.dss.advertisefields.create.from.template.wizard,model_dss_advertisefields_create_from_template_wizard,base.group_user,1,1,1,1 +digitalsignage_dss_advertisefields_create_from_project_wizard_group_user,access.dss.advertisefields.create.from.project.wizard,model_dss_advertisefields_create_from_project_wizard,base.group_user,1,1,1,1 +digitalsignage_dss_advertisefields_save_as_template_wizard_group_user,access.dss.advertisefields.save.as.template.wizard,model_dss_advertisefields_save_as_template_wizard,base.group_user,1,1,1,1 digitalsignage_dss_linkmail_group_user,access.dss.linkmail,model_dss_linkmail,base.group_user,1,1,1,1 digitalsignage_dss_kileads_requests,access.dss.kileads.requests,model_dss_kileads_requests,base.group_user,1,1,1,1 digitalsignage_dss_kileads,access.dss.kileads,model_dss_kileads,base.group_user,1,1,1,1 digitalsignage_dss_contracts_uservars_group_user,access.dss.contracts_uservars,model_dss_contracts_uservars,base.group_user,1,1,1,1 -digitalsignage_dss_settings_vars_group_user,access.dss.settings.vars,model_dss_settings_vars,base.group_user,1,1,1,1 \ No newline at end of file +digitalsignage_dss_settings_vars_group_user,access.dss.settings.vars,model_dss_settings_vars,base.group_user,1,1,1,1 +digitalsignage_dss_csvs_group_user,access.dss.csvs,model_dss_csvs,base.group_user,1,1,1,1 +digitalsignage_dss_ai_chat_history_group_user,access.dss.ai.chat.history,model_dss_ai_chat_history,base.group_user,1,1,1,1 +digitalsignage_dss_portal_user_profile_group_system,access.dss.portal.user.profile,model_dss_portal_user_profile,base.group_system,1,1,1,1 +digitalsignage_dss_phonecenter_query_group_user,access.dss.phonecenter.query,model_dss_phonecenter_query,base.group_user,1,1,1,1 +digitalsignage_dss_phonecenter_query_line_group_user,access.dss.phonecenter.query.line,model_dss_phonecenter_query_line,base.group_user,1,1,1,1 +digitalsignage_dss_contracts_move_field_wizard_group_user,access.dss.contracts.move.field.wizard,model_dss_contracts_move_field_wizard,base.group_user,1,1,1,1 \ No newline at end of file diff --git a/static/src/helpers/helper.html b/static/src/helpers/helper.html new file mode 100755 index 0000000..65011d8 --- /dev/null +++ b/static/src/helpers/helper.html @@ -0,0 +1,52 @@ + +
+ + + + + + + + + + + + +
+ \ No newline at end of file diff --git a/static/src/img/amazon.png b/static/src/img/amazon.png old mode 100644 new mode 100755 diff --git a/static/src/img/img_105_b.jpg b/static/src/img/img_105_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_105_b_2.jpg b/static/src/img/img_105_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_105_b_3.jpg b/static/src/img/img_105_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_113_b.jpg b/static/src/img/img_113_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_113_b_2.jpg b/static/src/img/img_113_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_113_b_3.jpg b/static/src/img/img_113_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_115_b.jpg b/static/src/img/img_115_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_117_b.jpg b/static/src/img/img_117_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_119_b.jpg b/static/src/img/img_119_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_119_b_2.jpg b/static/src/img/img_119_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_119_b_3.jpg b/static/src/img/img_119_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_121_b.jpg b/static/src/img/img_121_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_134_b.jpg b/static/src/img/img_134_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_134_b_2.jpg b/static/src/img/img_134_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_134_b_3.jpg b/static/src/img/img_134_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_135_b.jpg b/static/src/img/img_135_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_137_b.jpg b/static/src/img/img_137_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_137_b_2.jpg b/static/src/img/img_137_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_137_b_3.jpg b/static/src/img/img_137_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_138_b.jpg b/static/src/img/img_138_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_138_b_2.jpg b/static/src/img/img_138_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_138_b_3.jpg b/static/src/img/img_138_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_142_b.jpg b/static/src/img/img_142_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_142_b_2.jpg b/static/src/img/img_142_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_142_b_3.jpg b/static/src/img/img_142_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_145_b.jpg b/static/src/img/img_145_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_145_b_2.jpg b/static/src/img/img_145_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_145_b_3.jpg b/static/src/img/img_145_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_146_b.jpg b/static/src/img/img_146_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_146_b_2.jpg b/static/src/img/img_146_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_146_b_3.jpg b/static/src/img/img_146_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_148_b.jpg b/static/src/img/img_148_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_148_b_2.jpg b/static/src/img/img_148_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_14_b.jpg b/static/src/img/img_14_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_14_b_2.jpg b/static/src/img/img_14_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_14_b_3.jpg b/static/src/img/img_14_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_150_b.jpg b/static/src/img/img_150_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_150_b_2.jpg b/static/src/img/img_150_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_150_b_3.jpg b/static/src/img/img_150_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_151_b.jpg b/static/src/img/img_151_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_151_b_2.jpg b/static/src/img/img_151_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_151_b_3.jpg b/static/src/img/img_151_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_17_b.jpg b/static/src/img/img_17_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_18_b.jpg b/static/src/img/img_18_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_21_b.jpg b/static/src/img/img_21_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_21_b_2.jpg b/static/src/img/img_21_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_21_b_3.jpg b/static/src/img/img_21_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_225_b.jpg b/static/src/img/img_225_b.jpg new file mode 100755 index 0000000..161ada6 Binary files /dev/null and b/static/src/img/img_225_b.jpg differ diff --git a/static/src/img/img_225_b_2.jpg b/static/src/img/img_225_b_2.jpg new file mode 100755 index 0000000..96cee26 Binary files /dev/null and b/static/src/img/img_225_b_2.jpg differ diff --git a/static/src/img/img_225_b_3.jpg b/static/src/img/img_225_b_3.jpg new file mode 100755 index 0000000..f40d803 Binary files /dev/null and b/static/src/img/img_225_b_3.jpg differ diff --git a/static/src/img/img_229_b.jpg b/static/src/img/img_229_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_229_b_2.jpg b/static/src/img/img_229_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_241_b.jpg b/static/src/img/img_241_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_241_b_2.jpg b/static/src/img/img_241_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_241_b_3.jpg b/static/src/img/img_241_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_387_b.jpg b/static/src/img/img_387_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_387_b_2.jpg b/static/src/img/img_387_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_389_b.jpg b/static/src/img/img_389_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_389_b_2.jpg b/static/src/img/img_389_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_389_b_3.jpg b/static/src/img/img_389_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_4_b.jpg b/static/src/img/img_4_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_4_b_2.jpg b/static/src/img/img_4_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_5_b.jpg b/static/src/img/img_5_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_62_b.jpg b/static/src/img/img_62_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_62_b_2.jpg b/static/src/img/img_62_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_62_b_3.jpg b/static/src/img/img_62_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_63_b.jpg b/static/src/img/img_63_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_63_b_2.jpg b/static/src/img/img_63_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_63_b_3.jpg b/static/src/img/img_63_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_65_b.jpg b/static/src/img/img_65_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_65_b_2.jpg b/static/src/img/img_65_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_65_b_3.jpg b/static/src/img/img_65_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_66_b.jpg b/static/src/img/img_66_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_66_b_2.jpg b/static/src/img/img_66_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_66_b_3.jpg b/static/src/img/img_66_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_67_b.jpg b/static/src/img/img_67_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_68_b.jpg b/static/src/img/img_68_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_69_b.jpg b/static/src/img/img_69_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_70_b.jpg b/static/src/img/img_70_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_70_b_2.jpg b/static/src/img/img_70_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_71_b.jpg b/static/src/img/img_71_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_72_b.jpg b/static/src/img/img_72_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_72_b_2.jpg b/static/src/img/img_72_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_72_b_3.jpg b/static/src/img/img_72_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_73_b.jpg b/static/src/img/img_73_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_74_b.jpg b/static/src/img/img_74_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_74_b_2.jpg b/static/src/img/img_74_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_74_b_3.jpg b/static/src/img/img_74_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_76_b.jpg b/static/src/img/img_76_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_77_b.jpg b/static/src/img/img_77_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_78_b.jpg b/static/src/img/img_78_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_79_b.jpg b/static/src/img/img_79_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_79_b_2.jpg b/static/src/img/img_79_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_79_b_3.jpg b/static/src/img/img_79_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_81_b.jpg b/static/src/img/img_81_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_81_b_2.jpg b/static/src/img/img_81_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_81_b_3.jpg b/static/src/img/img_81_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_83_b.jpg b/static/src/img/img_83_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_84_b.jpg b/static/src/img/img_84_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_85_b.jpg b/static/src/img/img_85_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_86_b.jpg b/static/src/img/img_86_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_87_b.jpg b/static/src/img/img_87_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_88_b.jpg b/static/src/img/img_88_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_90_b.jpg b/static/src/img/img_90_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_90_b_2.jpg b/static/src/img/img_90_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_90_b_3.jpg b/static/src/img/img_90_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_91_b.jpg b/static/src/img/img_91_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_92_b.jpg b/static/src/img/img_92_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_92_b_3.jpg b/static/src/img/img_92_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_93_b.jpg b/static/src/img/img_93_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_94_b.jpg b/static/src/img/img_94_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_94_b_2.jpg b/static/src/img/img_94_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_94_b_3.jpg b/static/src/img/img_94_b_3.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_96_b.jpg b/static/src/img/img_96_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_97_b.jpg b/static/src/img/img_97_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_98_b.jpg b/static/src/img/img_98_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_98_b_2.jpg b/static/src/img/img_98_b_2.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/img_99_b.jpg b/static/src/img/img_99_b.jpg old mode 100644 new mode 100755 diff --git a/static/src/img/joyn.png b/static/src/img/joyn.png old mode 100644 new mode 100755 diff --git a/static/src/img/netflix.png b/static/src/img/netflix.png old mode 100644 new mode 100755 diff --git a/static/src/img/snippets/dss_container.svg b/static/src/img/snippets/dss_container.svg old mode 100644 new mode 100755 diff --git a/static/src/img/waipu.png b/static/src/img/waipu.png old mode 100644 new mode 100755 diff --git a/static/src/info.txt b/static/src/info.txt old mode 100644 new mode 100755 diff --git a/static/src/js/ace/info.txt b/static/src/js/ace/info.txt old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ace.js b/static/src/js/ace/src-noconflict/ace.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-beautify.js b/static/src/js/ace/src-noconflict/ext-beautify.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-code_lens.js b/static/src/js/ace/src-noconflict/ext-code_lens.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-command_bar.js b/static/src/js/ace/src-noconflict/ext-command_bar.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-diff.js b/static/src/js/ace/src-noconflict/ext-diff.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-elastic_tabstops_lite.js b/static/src/js/ace/src-noconflict/ext-elastic_tabstops_lite.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-emmet.js b/static/src/js/ace/src-noconflict/ext-emmet.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-error_marker.js b/static/src/js/ace/src-noconflict/ext-error_marker.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-hardwrap.js b/static/src/js/ace/src-noconflict/ext-hardwrap.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-inline_autocomplete.js b/static/src/js/ace/src-noconflict/ext-inline_autocomplete.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-keybinding_menu.js b/static/src/js/ace/src-noconflict/ext-keybinding_menu.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-language_tools.js b/static/src/js/ace/src-noconflict/ext-language_tools.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-linking.js b/static/src/js/ace/src-noconflict/ext-linking.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-modelist.js b/static/src/js/ace/src-noconflict/ext-modelist.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-options.js b/static/src/js/ace/src-noconflict/ext-options.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-prompt.js b/static/src/js/ace/src-noconflict/ext-prompt.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-rtl.js b/static/src/js/ace/src-noconflict/ext-rtl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-searchbox.js b/static/src/js/ace/src-noconflict/ext-searchbox.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-settings_menu.js b/static/src/js/ace/src-noconflict/ext-settings_menu.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-simple_tokenizer.js b/static/src/js/ace/src-noconflict/ext-simple_tokenizer.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-spellcheck.js b/static/src/js/ace/src-noconflict/ext-spellcheck.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-split.js b/static/src/js/ace/src-noconflict/ext-split.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-static_highlight.js b/static/src/js/ace/src-noconflict/ext-static_highlight.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-statusbar.js b/static/src/js/ace/src-noconflict/ext-statusbar.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-textarea.js b/static/src/js/ace/src-noconflict/ext-textarea.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-themelist.js b/static/src/js/ace/src-noconflict/ext-themelist.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/ext-whitespace.js b/static/src/js/ace/src-noconflict/ext-whitespace.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/info.txt b/static/src/js/ace/src-noconflict/info.txt old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/keybinding-emacs.js b/static/src/js/ace/src-noconflict/keybinding-emacs.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/keybinding-sublime.js b/static/src/js/ace/src-noconflict/keybinding-sublime.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/keybinding-vim.js b/static/src/js/ace/src-noconflict/keybinding-vim.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/keybinding-vscode.js b/static/src/js/ace/src-noconflict/keybinding-vscode.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-abap.js b/static/src/js/ace/src-noconflict/mode-abap.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-abc.js b/static/src/js/ace/src-noconflict/mode-abc.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-actionscript.js b/static/src/js/ace/src-noconflict/mode-actionscript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-ada.js b/static/src/js/ace/src-noconflict/mode-ada.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-alda.js b/static/src/js/ace/src-noconflict/mode-alda.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-apache_conf.js b/static/src/js/ace/src-noconflict/mode-apache_conf.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-apex.js b/static/src/js/ace/src-noconflict/mode-apex.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-applescript.js b/static/src/js/ace/src-noconflict/mode-applescript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-aql.js b/static/src/js/ace/src-noconflict/mode-aql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-asciidoc.js b/static/src/js/ace/src-noconflict/mode-asciidoc.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-asl.js b/static/src/js/ace/src-noconflict/mode-asl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-assembly_arm32.js b/static/src/js/ace/src-noconflict/mode-assembly_arm32.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-assembly_x86.js b/static/src/js/ace/src-noconflict/mode-assembly_x86.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-astro.js b/static/src/js/ace/src-noconflict/mode-astro.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-autohotkey.js b/static/src/js/ace/src-noconflict/mode-autohotkey.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-basic.js b/static/src/js/ace/src-noconflict/mode-basic.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-batchfile.js b/static/src/js/ace/src-noconflict/mode-batchfile.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-bibtex.js b/static/src/js/ace/src-noconflict/mode-bibtex.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-c9search.js b/static/src/js/ace/src-noconflict/mode-c9search.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-c_cpp.js b/static/src/js/ace/src-noconflict/mode-c_cpp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-cirru.js b/static/src/js/ace/src-noconflict/mode-cirru.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-clojure.js b/static/src/js/ace/src-noconflict/mode-clojure.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-clue.js b/static/src/js/ace/src-noconflict/mode-clue.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-cobol.js b/static/src/js/ace/src-noconflict/mode-cobol.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-coffee.js b/static/src/js/ace/src-noconflict/mode-coffee.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-coldfusion.js b/static/src/js/ace/src-noconflict/mode-coldfusion.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-crystal.js b/static/src/js/ace/src-noconflict/mode-crystal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-csharp.js b/static/src/js/ace/src-noconflict/mode-csharp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-csound_document.js b/static/src/js/ace/src-noconflict/mode-csound_document.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-csound_orchestra.js b/static/src/js/ace/src-noconflict/mode-csound_orchestra.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-csound_score.js b/static/src/js/ace/src-noconflict/mode-csound_score.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-csp.js b/static/src/js/ace/src-noconflict/mode-csp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-css.js b/static/src/js/ace/src-noconflict/mode-css.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-csv.js b/static/src/js/ace/src-noconflict/mode-csv.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-curly.js b/static/src/js/ace/src-noconflict/mode-curly.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-cuttlefish.js b/static/src/js/ace/src-noconflict/mode-cuttlefish.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-d.js b/static/src/js/ace/src-noconflict/mode-d.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-dart.js b/static/src/js/ace/src-noconflict/mode-dart.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-diff.js b/static/src/js/ace/src-noconflict/mode-diff.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-django.js b/static/src/js/ace/src-noconflict/mode-django.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-dockerfile.js b/static/src/js/ace/src-noconflict/mode-dockerfile.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-dot.js b/static/src/js/ace/src-noconflict/mode-dot.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-drools.js b/static/src/js/ace/src-noconflict/mode-drools.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-edifact.js b/static/src/js/ace/src-noconflict/mode-edifact.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-eiffel.js b/static/src/js/ace/src-noconflict/mode-eiffel.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-ejs.js b/static/src/js/ace/src-noconflict/mode-ejs.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-elixir.js b/static/src/js/ace/src-noconflict/mode-elixir.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-elm.js b/static/src/js/ace/src-noconflict/mode-elm.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-erlang.js b/static/src/js/ace/src-noconflict/mode-erlang.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-flix.js b/static/src/js/ace/src-noconflict/mode-flix.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-forth.js b/static/src/js/ace/src-noconflict/mode-forth.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-fortran.js b/static/src/js/ace/src-noconflict/mode-fortran.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-fsharp.js b/static/src/js/ace/src-noconflict/mode-fsharp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-fsl.js b/static/src/js/ace/src-noconflict/mode-fsl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-ftl.js b/static/src/js/ace/src-noconflict/mode-ftl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-gcode.js b/static/src/js/ace/src-noconflict/mode-gcode.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-gherkin.js b/static/src/js/ace/src-noconflict/mode-gherkin.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-gitignore.js b/static/src/js/ace/src-noconflict/mode-gitignore.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-glsl.js b/static/src/js/ace/src-noconflict/mode-glsl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-gobstones.js b/static/src/js/ace/src-noconflict/mode-gobstones.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-golang.js b/static/src/js/ace/src-noconflict/mode-golang.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-graphqlschema.js b/static/src/js/ace/src-noconflict/mode-graphqlschema.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-groovy.js b/static/src/js/ace/src-noconflict/mode-groovy.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-haml.js b/static/src/js/ace/src-noconflict/mode-haml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-handlebars.js b/static/src/js/ace/src-noconflict/mode-handlebars.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-haskell.js b/static/src/js/ace/src-noconflict/mode-haskell.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-haskell_cabal.js b/static/src/js/ace/src-noconflict/mode-haskell_cabal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-haxe.js b/static/src/js/ace/src-noconflict/mode-haxe.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-hjson.js b/static/src/js/ace/src-noconflict/mode-hjson.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-html.js b/static/src/js/ace/src-noconflict/mode-html.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-html_elixir.js b/static/src/js/ace/src-noconflict/mode-html_elixir.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-html_ruby.js b/static/src/js/ace/src-noconflict/mode-html_ruby.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-ini.js b/static/src/js/ace/src-noconflict/mode-ini.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-io.js b/static/src/js/ace/src-noconflict/mode-io.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-ion.js b/static/src/js/ace/src-noconflict/mode-ion.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-jack.js b/static/src/js/ace/src-noconflict/mode-jack.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-jade.js b/static/src/js/ace/src-noconflict/mode-jade.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-java.js b/static/src/js/ace/src-noconflict/mode-java.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-javascript.js b/static/src/js/ace/src-noconflict/mode-javascript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-jexl.js b/static/src/js/ace/src-noconflict/mode-jexl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-json.js b/static/src/js/ace/src-noconflict/mode-json.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-json5.js b/static/src/js/ace/src-noconflict/mode-json5.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-jsoniq.js b/static/src/js/ace/src-noconflict/mode-jsoniq.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-jsp.js b/static/src/js/ace/src-noconflict/mode-jsp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-jssm.js b/static/src/js/ace/src-noconflict/mode-jssm.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-jsx.js b/static/src/js/ace/src-noconflict/mode-jsx.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-julia.js b/static/src/js/ace/src-noconflict/mode-julia.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-kotlin.js b/static/src/js/ace/src-noconflict/mode-kotlin.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-latex.js b/static/src/js/ace/src-noconflict/mode-latex.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-latte.js b/static/src/js/ace/src-noconflict/mode-latte.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-less.js b/static/src/js/ace/src-noconflict/mode-less.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-liquid.js b/static/src/js/ace/src-noconflict/mode-liquid.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-lisp.js b/static/src/js/ace/src-noconflict/mode-lisp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-livescript.js b/static/src/js/ace/src-noconflict/mode-livescript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-logiql.js b/static/src/js/ace/src-noconflict/mode-logiql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-logtalk.js b/static/src/js/ace/src-noconflict/mode-logtalk.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-lsl.js b/static/src/js/ace/src-noconflict/mode-lsl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-lua.js b/static/src/js/ace/src-noconflict/mode-lua.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-luapage.js b/static/src/js/ace/src-noconflict/mode-luapage.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-lucene.js b/static/src/js/ace/src-noconflict/mode-lucene.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-makefile.js b/static/src/js/ace/src-noconflict/mode-makefile.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-markdown.js b/static/src/js/ace/src-noconflict/mode-markdown.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-mask.js b/static/src/js/ace/src-noconflict/mode-mask.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-matlab.js b/static/src/js/ace/src-noconflict/mode-matlab.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-maze.js b/static/src/js/ace/src-noconflict/mode-maze.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-mediawiki.js b/static/src/js/ace/src-noconflict/mode-mediawiki.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-mel.js b/static/src/js/ace/src-noconflict/mode-mel.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-mips.js b/static/src/js/ace/src-noconflict/mode-mips.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-mixal.js b/static/src/js/ace/src-noconflict/mode-mixal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-mushcode.js b/static/src/js/ace/src-noconflict/mode-mushcode.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-mysql.js b/static/src/js/ace/src-noconflict/mode-mysql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-nasal.js b/static/src/js/ace/src-noconflict/mode-nasal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-nginx.js b/static/src/js/ace/src-noconflict/mode-nginx.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-nim.js b/static/src/js/ace/src-noconflict/mode-nim.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-nix.js b/static/src/js/ace/src-noconflict/mode-nix.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-nsis.js b/static/src/js/ace/src-noconflict/mode-nsis.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-nunjucks.js b/static/src/js/ace/src-noconflict/mode-nunjucks.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-objectivec.js b/static/src/js/ace/src-noconflict/mode-objectivec.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-ocaml.js b/static/src/js/ace/src-noconflict/mode-ocaml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-odin.js b/static/src/js/ace/src-noconflict/mode-odin.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-partiql.js b/static/src/js/ace/src-noconflict/mode-partiql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-pascal.js b/static/src/js/ace/src-noconflict/mode-pascal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-perl.js b/static/src/js/ace/src-noconflict/mode-perl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-pgsql.js b/static/src/js/ace/src-noconflict/mode-pgsql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-php.js b/static/src/js/ace/src-noconflict/mode-php.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-php_laravel_blade.js b/static/src/js/ace/src-noconflict/mode-php_laravel_blade.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-pig.js b/static/src/js/ace/src-noconflict/mode-pig.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-plain_text.js b/static/src/js/ace/src-noconflict/mode-plain_text.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-plsql.js b/static/src/js/ace/src-noconflict/mode-plsql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-powershell.js b/static/src/js/ace/src-noconflict/mode-powershell.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-praat.js b/static/src/js/ace/src-noconflict/mode-praat.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-prisma.js b/static/src/js/ace/src-noconflict/mode-prisma.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-prolog.js b/static/src/js/ace/src-noconflict/mode-prolog.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-properties.js b/static/src/js/ace/src-noconflict/mode-properties.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-protobuf.js b/static/src/js/ace/src-noconflict/mode-protobuf.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-prql.js b/static/src/js/ace/src-noconflict/mode-prql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-puppet.js b/static/src/js/ace/src-noconflict/mode-puppet.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-python.js b/static/src/js/ace/src-noconflict/mode-python.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-qml.js b/static/src/js/ace/src-noconflict/mode-qml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-r.js b/static/src/js/ace/src-noconflict/mode-r.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-raku.js b/static/src/js/ace/src-noconflict/mode-raku.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-razor.js b/static/src/js/ace/src-noconflict/mode-razor.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-rdoc.js b/static/src/js/ace/src-noconflict/mode-rdoc.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-red.js b/static/src/js/ace/src-noconflict/mode-red.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-redshift.js b/static/src/js/ace/src-noconflict/mode-redshift.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-rhtml.js b/static/src/js/ace/src-noconflict/mode-rhtml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-robot.js b/static/src/js/ace/src-noconflict/mode-robot.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-rst.js b/static/src/js/ace/src-noconflict/mode-rst.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-ruby.js b/static/src/js/ace/src-noconflict/mode-ruby.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-rust.js b/static/src/js/ace/src-noconflict/mode-rust.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-sac.js b/static/src/js/ace/src-noconflict/mode-sac.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-sass.js b/static/src/js/ace/src-noconflict/mode-sass.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-scad.js b/static/src/js/ace/src-noconflict/mode-scad.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-scala.js b/static/src/js/ace/src-noconflict/mode-scala.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-scheme.js b/static/src/js/ace/src-noconflict/mode-scheme.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-scrypt.js b/static/src/js/ace/src-noconflict/mode-scrypt.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-scss.js b/static/src/js/ace/src-noconflict/mode-scss.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-sh.js b/static/src/js/ace/src-noconflict/mode-sh.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-sjs.js b/static/src/js/ace/src-noconflict/mode-sjs.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-slim.js b/static/src/js/ace/src-noconflict/mode-slim.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-smarty.js b/static/src/js/ace/src-noconflict/mode-smarty.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-smithy.js b/static/src/js/ace/src-noconflict/mode-smithy.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-snippets.js b/static/src/js/ace/src-noconflict/mode-snippets.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-soy_template.js b/static/src/js/ace/src-noconflict/mode-soy_template.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-space.js b/static/src/js/ace/src-noconflict/mode-space.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-sparql.js b/static/src/js/ace/src-noconflict/mode-sparql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-sql.js b/static/src/js/ace/src-noconflict/mode-sql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-sqlserver.js b/static/src/js/ace/src-noconflict/mode-sqlserver.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-stylus.js b/static/src/js/ace/src-noconflict/mode-stylus.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-svg.js b/static/src/js/ace/src-noconflict/mode-svg.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-swift.js b/static/src/js/ace/src-noconflict/mode-swift.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-tcl.js b/static/src/js/ace/src-noconflict/mode-tcl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-terraform.js b/static/src/js/ace/src-noconflict/mode-terraform.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-tex.js b/static/src/js/ace/src-noconflict/mode-tex.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-text.js b/static/src/js/ace/src-noconflict/mode-text.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-textile.js b/static/src/js/ace/src-noconflict/mode-textile.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-toml.js b/static/src/js/ace/src-noconflict/mode-toml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-tsv.js b/static/src/js/ace/src-noconflict/mode-tsv.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-tsx.js b/static/src/js/ace/src-noconflict/mode-tsx.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-turtle.js b/static/src/js/ace/src-noconflict/mode-turtle.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-twig.js b/static/src/js/ace/src-noconflict/mode-twig.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-typescript.js b/static/src/js/ace/src-noconflict/mode-typescript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-vala.js b/static/src/js/ace/src-noconflict/mode-vala.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-vbscript.js b/static/src/js/ace/src-noconflict/mode-vbscript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-velocity.js b/static/src/js/ace/src-noconflict/mode-velocity.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-verilog.js b/static/src/js/ace/src-noconflict/mode-verilog.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-vhdl.js b/static/src/js/ace/src-noconflict/mode-vhdl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-visualforce.js b/static/src/js/ace/src-noconflict/mode-visualforce.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-vue.js b/static/src/js/ace/src-noconflict/mode-vue.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-wollok.js b/static/src/js/ace/src-noconflict/mode-wollok.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-xml.js b/static/src/js/ace/src-noconflict/mode-xml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-xquery.js b/static/src/js/ace/src-noconflict/mode-xquery.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-yaml.js b/static/src/js/ace/src-noconflict/mode-yaml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-zeek.js b/static/src/js/ace/src-noconflict/mode-zeek.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/mode-zig.js b/static/src/js/ace/src-noconflict/mode-zig.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/abap.js b/static/src/js/ace/src-noconflict/snippets/abap.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/abc.js b/static/src/js/ace/src-noconflict/snippets/abc.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/actionscript.js b/static/src/js/ace/src-noconflict/snippets/actionscript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/ada.js b/static/src/js/ace/src-noconflict/snippets/ada.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/alda.js b/static/src/js/ace/src-noconflict/snippets/alda.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/apache_conf.js b/static/src/js/ace/src-noconflict/snippets/apache_conf.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/apex.js b/static/src/js/ace/src-noconflict/snippets/apex.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/applescript.js b/static/src/js/ace/src-noconflict/snippets/applescript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/aql.js b/static/src/js/ace/src-noconflict/snippets/aql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/asciidoc.js b/static/src/js/ace/src-noconflict/snippets/asciidoc.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/asl.js b/static/src/js/ace/src-noconflict/snippets/asl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/assembly_arm32.js b/static/src/js/ace/src-noconflict/snippets/assembly_arm32.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/assembly_x86.js b/static/src/js/ace/src-noconflict/snippets/assembly_x86.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/astro.js b/static/src/js/ace/src-noconflict/snippets/astro.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/autohotkey.js b/static/src/js/ace/src-noconflict/snippets/autohotkey.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/basic.js b/static/src/js/ace/src-noconflict/snippets/basic.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/batchfile.js b/static/src/js/ace/src-noconflict/snippets/batchfile.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/bibtex.js b/static/src/js/ace/src-noconflict/snippets/bibtex.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/c9search.js b/static/src/js/ace/src-noconflict/snippets/c9search.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/c_cpp.js b/static/src/js/ace/src-noconflict/snippets/c_cpp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/cirru.js b/static/src/js/ace/src-noconflict/snippets/cirru.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/clojure.js b/static/src/js/ace/src-noconflict/snippets/clojure.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/clue.js b/static/src/js/ace/src-noconflict/snippets/clue.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/cobol.js b/static/src/js/ace/src-noconflict/snippets/cobol.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/coffee.js b/static/src/js/ace/src-noconflict/snippets/coffee.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/coldfusion.js b/static/src/js/ace/src-noconflict/snippets/coldfusion.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/crystal.js b/static/src/js/ace/src-noconflict/snippets/crystal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/csharp.js b/static/src/js/ace/src-noconflict/snippets/csharp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/csound_document.js b/static/src/js/ace/src-noconflict/snippets/csound_document.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/csound_orchestra.js b/static/src/js/ace/src-noconflict/snippets/csound_orchestra.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/csound_score.js b/static/src/js/ace/src-noconflict/snippets/csound_score.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/csp.js b/static/src/js/ace/src-noconflict/snippets/csp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/css.js b/static/src/js/ace/src-noconflict/snippets/css.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/csv.js b/static/src/js/ace/src-noconflict/snippets/csv.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/curly.js b/static/src/js/ace/src-noconflict/snippets/curly.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/cuttlefish.js b/static/src/js/ace/src-noconflict/snippets/cuttlefish.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/d.js b/static/src/js/ace/src-noconflict/snippets/d.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/dart.js b/static/src/js/ace/src-noconflict/snippets/dart.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/diff.js b/static/src/js/ace/src-noconflict/snippets/diff.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/django.js b/static/src/js/ace/src-noconflict/snippets/django.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/dockerfile.js b/static/src/js/ace/src-noconflict/snippets/dockerfile.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/dot.js b/static/src/js/ace/src-noconflict/snippets/dot.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/drools.js b/static/src/js/ace/src-noconflict/snippets/drools.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/edifact.js b/static/src/js/ace/src-noconflict/snippets/edifact.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/eiffel.js b/static/src/js/ace/src-noconflict/snippets/eiffel.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/ejs.js b/static/src/js/ace/src-noconflict/snippets/ejs.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/elixir.js b/static/src/js/ace/src-noconflict/snippets/elixir.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/elm.js b/static/src/js/ace/src-noconflict/snippets/elm.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/erlang.js b/static/src/js/ace/src-noconflict/snippets/erlang.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/flix.js b/static/src/js/ace/src-noconflict/snippets/flix.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/forth.js b/static/src/js/ace/src-noconflict/snippets/forth.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/fortran.js b/static/src/js/ace/src-noconflict/snippets/fortran.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/fsharp.js b/static/src/js/ace/src-noconflict/snippets/fsharp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/fsl.js b/static/src/js/ace/src-noconflict/snippets/fsl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/ftl.js b/static/src/js/ace/src-noconflict/snippets/ftl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/gcode.js b/static/src/js/ace/src-noconflict/snippets/gcode.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/gherkin.js b/static/src/js/ace/src-noconflict/snippets/gherkin.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/gitignore.js b/static/src/js/ace/src-noconflict/snippets/gitignore.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/glsl.js b/static/src/js/ace/src-noconflict/snippets/glsl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/gobstones.js b/static/src/js/ace/src-noconflict/snippets/gobstones.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/golang.js b/static/src/js/ace/src-noconflict/snippets/golang.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/graphqlschema.js b/static/src/js/ace/src-noconflict/snippets/graphqlschema.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/groovy.js b/static/src/js/ace/src-noconflict/snippets/groovy.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/haml.js b/static/src/js/ace/src-noconflict/snippets/haml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/handlebars.js b/static/src/js/ace/src-noconflict/snippets/handlebars.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/haskell.js b/static/src/js/ace/src-noconflict/snippets/haskell.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/haskell_cabal.js b/static/src/js/ace/src-noconflict/snippets/haskell_cabal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/haxe.js b/static/src/js/ace/src-noconflict/snippets/haxe.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/hjson.js b/static/src/js/ace/src-noconflict/snippets/hjson.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/html.js b/static/src/js/ace/src-noconflict/snippets/html.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/html_elixir.js b/static/src/js/ace/src-noconflict/snippets/html_elixir.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/html_ruby.js b/static/src/js/ace/src-noconflict/snippets/html_ruby.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/ini.js b/static/src/js/ace/src-noconflict/snippets/ini.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/io.js b/static/src/js/ace/src-noconflict/snippets/io.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/ion.js b/static/src/js/ace/src-noconflict/snippets/ion.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/jack.js b/static/src/js/ace/src-noconflict/snippets/jack.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/jade.js b/static/src/js/ace/src-noconflict/snippets/jade.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/java.js b/static/src/js/ace/src-noconflict/snippets/java.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/javascript.js b/static/src/js/ace/src-noconflict/snippets/javascript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/jexl.js b/static/src/js/ace/src-noconflict/snippets/jexl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/json.js b/static/src/js/ace/src-noconflict/snippets/json.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/json5.js b/static/src/js/ace/src-noconflict/snippets/json5.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/jsoniq.js b/static/src/js/ace/src-noconflict/snippets/jsoniq.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/jsp.js b/static/src/js/ace/src-noconflict/snippets/jsp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/jssm.js b/static/src/js/ace/src-noconflict/snippets/jssm.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/jsx.js b/static/src/js/ace/src-noconflict/snippets/jsx.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/julia.js b/static/src/js/ace/src-noconflict/snippets/julia.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/kotlin.js b/static/src/js/ace/src-noconflict/snippets/kotlin.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/latex.js b/static/src/js/ace/src-noconflict/snippets/latex.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/latte.js b/static/src/js/ace/src-noconflict/snippets/latte.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/less.js b/static/src/js/ace/src-noconflict/snippets/less.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/liquid.js b/static/src/js/ace/src-noconflict/snippets/liquid.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/lisp.js b/static/src/js/ace/src-noconflict/snippets/lisp.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/livescript.js b/static/src/js/ace/src-noconflict/snippets/livescript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/logiql.js b/static/src/js/ace/src-noconflict/snippets/logiql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/logtalk.js b/static/src/js/ace/src-noconflict/snippets/logtalk.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/lsl.js b/static/src/js/ace/src-noconflict/snippets/lsl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/lua.js b/static/src/js/ace/src-noconflict/snippets/lua.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/luapage.js b/static/src/js/ace/src-noconflict/snippets/luapage.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/lucene.js b/static/src/js/ace/src-noconflict/snippets/lucene.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/makefile.js b/static/src/js/ace/src-noconflict/snippets/makefile.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/markdown.js b/static/src/js/ace/src-noconflict/snippets/markdown.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/mask.js b/static/src/js/ace/src-noconflict/snippets/mask.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/matlab.js b/static/src/js/ace/src-noconflict/snippets/matlab.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/maze.js b/static/src/js/ace/src-noconflict/snippets/maze.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/mediawiki.js b/static/src/js/ace/src-noconflict/snippets/mediawiki.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/mel.js b/static/src/js/ace/src-noconflict/snippets/mel.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/mips.js b/static/src/js/ace/src-noconflict/snippets/mips.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/mixal.js b/static/src/js/ace/src-noconflict/snippets/mixal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/mushcode.js b/static/src/js/ace/src-noconflict/snippets/mushcode.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/mysql.js b/static/src/js/ace/src-noconflict/snippets/mysql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/nasal.js b/static/src/js/ace/src-noconflict/snippets/nasal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/nginx.js b/static/src/js/ace/src-noconflict/snippets/nginx.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/nim.js b/static/src/js/ace/src-noconflict/snippets/nim.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/nix.js b/static/src/js/ace/src-noconflict/snippets/nix.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/nsis.js b/static/src/js/ace/src-noconflict/snippets/nsis.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/nunjucks.js b/static/src/js/ace/src-noconflict/snippets/nunjucks.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/objectivec.js b/static/src/js/ace/src-noconflict/snippets/objectivec.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/ocaml.js b/static/src/js/ace/src-noconflict/snippets/ocaml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/odin.js b/static/src/js/ace/src-noconflict/snippets/odin.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/partiql.js b/static/src/js/ace/src-noconflict/snippets/partiql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/pascal.js b/static/src/js/ace/src-noconflict/snippets/pascal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/perl.js b/static/src/js/ace/src-noconflict/snippets/perl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/pgsql.js b/static/src/js/ace/src-noconflict/snippets/pgsql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/php.js b/static/src/js/ace/src-noconflict/snippets/php.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/php_laravel_blade.js b/static/src/js/ace/src-noconflict/snippets/php_laravel_blade.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/pig.js b/static/src/js/ace/src-noconflict/snippets/pig.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/plain_text.js b/static/src/js/ace/src-noconflict/snippets/plain_text.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/plsql.js b/static/src/js/ace/src-noconflict/snippets/plsql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/powershell.js b/static/src/js/ace/src-noconflict/snippets/powershell.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/praat.js b/static/src/js/ace/src-noconflict/snippets/praat.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/prisma.js b/static/src/js/ace/src-noconflict/snippets/prisma.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/prolog.js b/static/src/js/ace/src-noconflict/snippets/prolog.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/properties.js b/static/src/js/ace/src-noconflict/snippets/properties.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/protobuf.js b/static/src/js/ace/src-noconflict/snippets/protobuf.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/prql.js b/static/src/js/ace/src-noconflict/snippets/prql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/puppet.js b/static/src/js/ace/src-noconflict/snippets/puppet.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/python.js b/static/src/js/ace/src-noconflict/snippets/python.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/qml.js b/static/src/js/ace/src-noconflict/snippets/qml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/r.js b/static/src/js/ace/src-noconflict/snippets/r.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/raku.js b/static/src/js/ace/src-noconflict/snippets/raku.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/razor.js b/static/src/js/ace/src-noconflict/snippets/razor.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/rdoc.js b/static/src/js/ace/src-noconflict/snippets/rdoc.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/red.js b/static/src/js/ace/src-noconflict/snippets/red.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/redshift.js b/static/src/js/ace/src-noconflict/snippets/redshift.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/rhtml.js b/static/src/js/ace/src-noconflict/snippets/rhtml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/robot.js b/static/src/js/ace/src-noconflict/snippets/robot.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/rst.js b/static/src/js/ace/src-noconflict/snippets/rst.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/ruby.js b/static/src/js/ace/src-noconflict/snippets/ruby.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/rust.js b/static/src/js/ace/src-noconflict/snippets/rust.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/sac.js b/static/src/js/ace/src-noconflict/snippets/sac.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/sass.js b/static/src/js/ace/src-noconflict/snippets/sass.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/scad.js b/static/src/js/ace/src-noconflict/snippets/scad.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/scala.js b/static/src/js/ace/src-noconflict/snippets/scala.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/scheme.js b/static/src/js/ace/src-noconflict/snippets/scheme.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/scrypt.js b/static/src/js/ace/src-noconflict/snippets/scrypt.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/scss.js b/static/src/js/ace/src-noconflict/snippets/scss.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/sh.js b/static/src/js/ace/src-noconflict/snippets/sh.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/sjs.js b/static/src/js/ace/src-noconflict/snippets/sjs.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/slim.js b/static/src/js/ace/src-noconflict/snippets/slim.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/smarty.js b/static/src/js/ace/src-noconflict/snippets/smarty.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/smithy.js b/static/src/js/ace/src-noconflict/snippets/smithy.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/snippets.js b/static/src/js/ace/src-noconflict/snippets/snippets.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/soy_template.js b/static/src/js/ace/src-noconflict/snippets/soy_template.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/space.js b/static/src/js/ace/src-noconflict/snippets/space.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/sparql.js b/static/src/js/ace/src-noconflict/snippets/sparql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/sql.js b/static/src/js/ace/src-noconflict/snippets/sql.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/sqlserver.js b/static/src/js/ace/src-noconflict/snippets/sqlserver.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/stylus.js b/static/src/js/ace/src-noconflict/snippets/stylus.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/svg.js b/static/src/js/ace/src-noconflict/snippets/svg.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/swift.js b/static/src/js/ace/src-noconflict/snippets/swift.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/tcl.js b/static/src/js/ace/src-noconflict/snippets/tcl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/terraform.js b/static/src/js/ace/src-noconflict/snippets/terraform.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/tex.js b/static/src/js/ace/src-noconflict/snippets/tex.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/text.js b/static/src/js/ace/src-noconflict/snippets/text.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/textile.js b/static/src/js/ace/src-noconflict/snippets/textile.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/toml.js b/static/src/js/ace/src-noconflict/snippets/toml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/tsv.js b/static/src/js/ace/src-noconflict/snippets/tsv.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/tsx.js b/static/src/js/ace/src-noconflict/snippets/tsx.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/turtle.js b/static/src/js/ace/src-noconflict/snippets/turtle.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/twig.js b/static/src/js/ace/src-noconflict/snippets/twig.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/typescript.js b/static/src/js/ace/src-noconflict/snippets/typescript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/vala.js b/static/src/js/ace/src-noconflict/snippets/vala.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/vbscript.js b/static/src/js/ace/src-noconflict/snippets/vbscript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/velocity.js b/static/src/js/ace/src-noconflict/snippets/velocity.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/verilog.js b/static/src/js/ace/src-noconflict/snippets/verilog.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/vhdl.js b/static/src/js/ace/src-noconflict/snippets/vhdl.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/visualforce.js b/static/src/js/ace/src-noconflict/snippets/visualforce.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/vue.js b/static/src/js/ace/src-noconflict/snippets/vue.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/wollok.js b/static/src/js/ace/src-noconflict/snippets/wollok.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/xml.js b/static/src/js/ace/src-noconflict/snippets/xml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/xquery.js b/static/src/js/ace/src-noconflict/snippets/xquery.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/yaml.js b/static/src/js/ace/src-noconflict/snippets/yaml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/zeek.js b/static/src/js/ace/src-noconflict/snippets/zeek.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/snippets/zig.js b/static/src/js/ace/src-noconflict/snippets/zig.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-ambiance.js b/static/src/js/ace/src-noconflict/theme-ambiance.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-chaos.js b/static/src/js/ace/src-noconflict/theme-chaos.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-chrome.js b/static/src/js/ace/src-noconflict/theme-chrome.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-cloud9_day.js b/static/src/js/ace/src-noconflict/theme-cloud9_day.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-cloud9_night.js b/static/src/js/ace/src-noconflict/theme-cloud9_night.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-cloud9_night_low_color.js b/static/src/js/ace/src-noconflict/theme-cloud9_night_low_color.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-cloud_editor.js b/static/src/js/ace/src-noconflict/theme-cloud_editor.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-cloud_editor_dark.js b/static/src/js/ace/src-noconflict/theme-cloud_editor_dark.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-clouds.js b/static/src/js/ace/src-noconflict/theme-clouds.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-clouds_midnight.js b/static/src/js/ace/src-noconflict/theme-clouds_midnight.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-cobalt.js b/static/src/js/ace/src-noconflict/theme-cobalt.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-crimson_editor.js b/static/src/js/ace/src-noconflict/theme-crimson_editor.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-dawn.js b/static/src/js/ace/src-noconflict/theme-dawn.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-dracula.js b/static/src/js/ace/src-noconflict/theme-dracula.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-dreamweaver.js b/static/src/js/ace/src-noconflict/theme-dreamweaver.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-eclipse.js b/static/src/js/ace/src-noconflict/theme-eclipse.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-github.js b/static/src/js/ace/src-noconflict/theme-github.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-github_dark.js b/static/src/js/ace/src-noconflict/theme-github_dark.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-github_light_default.js b/static/src/js/ace/src-noconflict/theme-github_light_default.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-gob.js b/static/src/js/ace/src-noconflict/theme-gob.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-gruvbox.js b/static/src/js/ace/src-noconflict/theme-gruvbox.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-gruvbox_dark_hard.js b/static/src/js/ace/src-noconflict/theme-gruvbox_dark_hard.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-gruvbox_light_hard.js b/static/src/js/ace/src-noconflict/theme-gruvbox_light_hard.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-idle_fingers.js b/static/src/js/ace/src-noconflict/theme-idle_fingers.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-iplastic.js b/static/src/js/ace/src-noconflict/theme-iplastic.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-katzenmilch.js b/static/src/js/ace/src-noconflict/theme-katzenmilch.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-kr_theme.js b/static/src/js/ace/src-noconflict/theme-kr_theme.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-kuroir.js b/static/src/js/ace/src-noconflict/theme-kuroir.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-merbivore.js b/static/src/js/ace/src-noconflict/theme-merbivore.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-merbivore_soft.js b/static/src/js/ace/src-noconflict/theme-merbivore_soft.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-mono_industrial.js b/static/src/js/ace/src-noconflict/theme-mono_industrial.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-monokai.js b/static/src/js/ace/src-noconflict/theme-monokai.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-nord_dark.js b/static/src/js/ace/src-noconflict/theme-nord_dark.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-one_dark.js b/static/src/js/ace/src-noconflict/theme-one_dark.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-pastel_on_dark.js b/static/src/js/ace/src-noconflict/theme-pastel_on_dark.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-solarized_dark.js b/static/src/js/ace/src-noconflict/theme-solarized_dark.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-solarized_light.js b/static/src/js/ace/src-noconflict/theme-solarized_light.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-sqlserver.js b/static/src/js/ace/src-noconflict/theme-sqlserver.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-terminal.js b/static/src/js/ace/src-noconflict/theme-terminal.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-textmate.js b/static/src/js/ace/src-noconflict/theme-textmate.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-tomorrow.js b/static/src/js/ace/src-noconflict/theme-tomorrow.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-tomorrow_night.js b/static/src/js/ace/src-noconflict/theme-tomorrow_night.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-tomorrow_night_blue.js b/static/src/js/ace/src-noconflict/theme-tomorrow_night_blue.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-tomorrow_night_bright.js b/static/src/js/ace/src-noconflict/theme-tomorrow_night_bright.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-tomorrow_night_eighties.js b/static/src/js/ace/src-noconflict/theme-tomorrow_night_eighties.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-twilight.js b/static/src/js/ace/src-noconflict/theme-twilight.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-vibrant_ink.js b/static/src/js/ace/src-noconflict/theme-vibrant_ink.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/theme-xcode.js b/static/src/js/ace/src-noconflict/theme-xcode.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-base.js b/static/src/js/ace/src-noconflict/worker-base.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-coffee.js b/static/src/js/ace/src-noconflict/worker-coffee.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-css.js b/static/src/js/ace/src-noconflict/worker-css.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-html.js b/static/src/js/ace/src-noconflict/worker-html.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-javascript.js b/static/src/js/ace/src-noconflict/worker-javascript.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-json.js b/static/src/js/ace/src-noconflict/worker-json.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-lua.js b/static/src/js/ace/src-noconflict/worker-lua.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-php.js b/static/src/js/ace/src-noconflict/worker-php.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-xml.js b/static/src/js/ace/src-noconflict/worker-xml.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-xquery.js b/static/src/js/ace/src-noconflict/worker-xquery.js old mode 100644 new mode 100755 diff --git a/static/src/js/ace/src-noconflict/worker-yaml.js b/static/src/js/ace/src-noconflict/worker-yaml.js old mode 100644 new mode 100755 diff --git a/static/src/js/blockly_blocks.js b/static/src/js/blockly_blocks.js old mode 100644 new mode 100755 diff --git a/static/src/js/dss_crm_extended_ki_leads.js b/static/src/js/dss_crm_extended_ki_leads.js old mode 100644 new mode 100755 diff --git a/static/src/js/dss_form_submit.js b/static/src/js/dss_form_submit.js old mode 100644 new mode 100755 diff --git a/static/src/js/dss_google_map.js b/static/src/js/dss_google_map.js index 7ef788f..e4b3d9e 100755 --- a/static/src/js/dss_google_map.js +++ b/static/src/js/dss_google_map.js @@ -40,105 +40,106 @@ function initialize_map() { } infoWindow.setContent( - '
' + - '
' + - (partner_url.length ? '' + p.google_name + '' : '' + p.google_name + '
: ') + - '
' + - '
' + - '' + - '' + - '
    ' + - '
  • ' + - '
    ' + - (p.google_image ? '


    ' : '') + - '
    ' + - '
  • '+ - '
  • ' + - '
    ' + - (p.google_image_2 ? '


    ' : '') + - '
    ' + - '
  • '+ - '
  • ' + - '
' + - '
' + - '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + FFS + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
Anzeigesystem' + - (p.google_typ ? '
' + p.google_typ + '' : '') + - '
Interaktiv' + - (p.google_typ_interactive ? '
' + p.google_typ_interactive + '' : '') + - '
Besucherzahl' + - (p.google_visitors ? '
' + p.google_visitors + '' : '') + - '
Anzeigetyp' + - (p.google_displaytyp ? '
' + p.google_displaytyp + '' : '') + - '
Anzeigegrösse' + - (p.google_displaysize ? '
' + p.google_displaysize + '' : '') + - '
Mögliche Werbeart 1 ' + - (p.google_displayflaechen_a ? '
' + p.google_displayflaechen_a + '' : '') + - '
Werbeart 1 Verfügbar ?' + - (p.google_displayflaechen_a_free ? '
' + p.google_displayflaechen_a_free + '' : '') + - '
Mögliche Werbeart 2' + - (p.google_displayflaechen_b ? '
' + p.google_displayflaechen_b + '' : '') + - '
Werbeart 2 Verfügbar ?' + - (p.google_displayflaechen_b_free ? '
' + p.google_displayflaechen_b_free + '' : '') + - '
Nötige Werbemittel' + - (p.google_displayfiles ? '
' + p.google_displayfiles + '' : '') + - '
Buchungscode' + - (p.google_id ? '
' + p.google_id + '' : '') + - '
' + - '
' + - '
' + - (p.google_text ? '
' + p.google_text + '' : '') + - '
' + - '
' + `
+
+ ${partner_url.length ? '' + p.google_name + '' : '' + p.google_name + '
: '} +
+
+ + +
    +
  • +
    + ${p.google_image ? '


    ' : ''} + +
    +
  • +
  • +
    + ${p.google_image_2 ? '


    ' : ''} +
    +
  • +
  • +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${FFS} + + + + + + + + +
Anzeigesystem + ${p.google_typ ? '
' + p.google_typ + '' : ''} +
Interaktiv + ${p.google_typ_interactive ? '
' + p.google_typ_interactive + '' : ''} +
Besucherzahl + ${p.google_visitors ? '
' + p.google_visitors + '' : ''} +
Anzeigetyp + ${p.google_displaytyp ? '
' + p.google_displaytyp + '' : ''} +
Anzeigegrösse + ${p.google_displaysize ? '
' + p.google_displaysize + '' : ''} +
Mögliche Werbeart 1 + ${p.google_displayflaechen_a ? '
' + p.google_displayflaechen_a + '' : ''} +
Werbeart 1 Verfügbar ? + ${p.google_displayflaechen_a_free ? '
' + p.google_displayflaechen_a_free + '' : ''} +
Mögliche Werbeart 2 + ${p.google_displayflaechen_b ? '
' + p.google_displayflaechen_b + '' : ''} +
Werbeart 2 Verfügbar ? + ${p.google_displayflaechen_b_free ? '
' + p.google_displayflaechen_b_free + '' : ''} +
Nötige Werbemittel + ${p.google_displayfiles ? '
' + p.google_displayfiles + '' : ''} +
Buchungscode + ${p.google_id ? '
' + p.google_id + '' : ''} +
+
+
+ ${p.google_text ? '
' + p.google_text + '' : ''} +
+
` ); infoWindow.open(map, marker); const slidesContainer = document.getElementById("slides-container"); diff --git a/static/src/js/dss_screenview_controller.js b/static/src/js/dss_screenview_controller.js index c5bb6be..0a60bd8 100755 --- a/static/src/js/dss_screenview_controller.js +++ b/static/src/js/dss_screenview_controller.js @@ -1,8 +1,9 @@ /** @odoo-module **/ import { Layout } from "@web/search/layout"; +import { SearchBar } from "@web/search/search_bar/search_bar"; import { useBus, useService } from "@web/core/utils/hooks"; import { useModel } from "@web/views/model"; -import { Component, onWillStart, useState} from "@odoo/owl"; +import { Component, onMounted, onWillStart, onWillUnmount, useState} from "@odoo/owl"; import { ScreenViewRenderer } from "./dss_screenview_renderer"; const { onRendered } = owl; @@ -15,6 +16,8 @@ export class ScreenViewController extends Component { this.user = useService("user"); this.viewService = useService("view"); this.ui = useService("ui"); + this.notification = useService("notification"); + this.state = useState({ showProjectFields: false }); this.resModel = this.props.resModel this.resDomain = this.props.domain // useBus(this.ui.bus, "resize", this.render); @@ -46,19 +49,139 @@ export class ScreenViewController extends Component { // ); onWillStart(async () => { - await this.model.load(); + await this.model.load(this.state.showProjectFields); }); onRendered(() => { this.env.config.setDisplayName(this.displayName()); }); + + this._boundPrintClickHandler = this._handleGlobalPrintClick.bind(this); + onMounted(() => { + document.addEventListener("click", this._boundPrintClickHandler, true); + }); + onWillUnmount(() => { + document.removeEventListener("click", this._boundPrintClickHandler, true); + }); } openView(domain, views, context) { alert('openview'); } + async onToggleProjectFields(event) { + this.state.showProjectFields = !!event.target.checked; + await this.model.load(this.state.showProjectFields); + } + + _handleGlobalPrintClick(event) { + const button = event.target && event.target.closest(".o_screenview_print_button"); + if (!button) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.onPrintClick(); + } + + onPrintClick() { + const screenviewElement = + (this.el && this.el.querySelector(".screenview_main")) || + document.querySelector(".o_action_manager .screenview_main"); + if (!screenviewElement) { + this.notification.add("Screenview-Bereich konnte zum Drucken nicht gefunden werden.", { + type: "warning", + }); + return; + } + + const rect = screenviewElement.getBoundingClientRect(); + const width = Math.max(1, Math.round(rect.width)); + const height = Math.max(1, Math.round(rect.height)); + + const clonedScreenview = screenviewElement.cloneNode(true); + + const printRoot = document.createElement("div"); + printRoot.id = "o_screenview_print_root"; + printRoot.style.position = "relative"; + printRoot.style.zIndex = "999999"; + printRoot.style.background = "#fff"; + printRoot.style.display = "block"; + printRoot.style.padding = "0"; + printRoot.style.overflow = "hidden"; + printRoot.style.margin = "0 auto"; + + const pageSizePx = { width: 1123, height: 794 }; // A4 landscape at ~96dpi + const maxWidth = pageSizePx.width - 10; + const maxHeight = pageSizePx.height - 10; + const scale = Math.min(maxWidth / width, maxHeight / height); + const scaledWidth = Math.max(1, Math.floor(width * scale)); + const scaledHeight = Math.max(1, Math.floor(height * scale)); + + printRoot.style.width = `${scaledWidth}px`; + printRoot.style.height = `${scaledHeight}px`; + + const scaledHost = document.createElement("div"); + scaledHost.style.width = `${scaledWidth}px`; + scaledHost.style.height = `${scaledHeight}px`; + scaledHost.style.overflow = "hidden"; + + clonedScreenview.style.width = `${width}px`; + clonedScreenview.style.height = `${height}px`; + clonedScreenview.style.transformOrigin = "top left"; + clonedScreenview.style.transform = `scale(${scale})`; + clonedScreenview.style.background = "#fff"; + scaledHost.appendChild(clonedScreenview); + printRoot.appendChild(scaledHost); + + const printStyle = document.createElement("style"); + printStyle.id = "o_screenview_print_style"; + printStyle.textContent = ` +@page { size: A4 landscape; margin: 2mm; } +@media print { + html, body { + margin: 0 !important; + padding: 0 !important; + background: #fff !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + body.o_screenview_printing > * { + display: none !important; + } + body.o_screenview_printing #o_screenview_print_root { + display: block !important; + margin: 0 auto !important; + page-break-inside: avoid !important; + break-inside: avoid-page !important; + } +} + `; + + const cleanup = () => { + const root = document.getElementById("o_screenview_print_root"); + const style = document.getElementById("o_screenview_print_style"); + if (root) { + root.remove(); + } + if (style) { + style.remove(); + } + document.body.classList.remove("o_screenview_printing"); + }; + + cleanup(); + document.head.appendChild(printStyle); + document.body.appendChild(printRoot); + document.body.classList.add("o_screenview_printing"); + window.addEventListener("afterprint", cleanup, { once: true }); + setTimeout(() => { + window.print(); + setTimeout(cleanup, 1200); + }, 50); + } + displayName() { return "DigitalSignage Projekt Vertraege - ScreenView "; } @@ -66,4 +189,4 @@ export class ScreenViewController extends Component { }; ScreenViewController.template = "dss.screenview"; -ScreenViewController.components = { Layout, ScreenViewRenderer }; +ScreenViewController.components = { Layout, SearchBar, ScreenViewRenderer }; diff --git a/static/src/js/dss_screenview_model.js b/static/src/js/dss_screenview_model.js index 5bca432..9fc54db 100755 --- a/static/src/js/dss_screenview_model.js +++ b/static/src/js/dss_screenview_model.js @@ -31,16 +31,16 @@ export class ScreenViewModel { // this.domain = [] // } - async load() { + async load(includeProjectFields = false) { //The keeplast protect against concurrency call console.log(this); - await this.keepLast.add(this.loaddata(this)); //this.loaddata(records)); + await this.keepLast.add(this.loaddata(includeProjectFields)); //this.loaddata(records)); } /** * @protected */ - async loaddata(data) { + async loaddata(includeProjectFields = false) { var newrecords = []; var nullrecord = {}; var x = 0; @@ -63,16 +63,90 @@ export class ScreenViewModel { newrecords[0] = nullrecord; x=1; const { length, records } = await this.orm.webSearchRead('dss.contracts', this.domain, [],{}); + + const contractIds = records.map((record) => record.id); + const stempelByContract = {}; + if (contractIds.length) { + const binaries = await this.orm.searchRead( + 'dss.binaries', + [['binary_contract', 'in', contractIds]], + ['binary_contract', 'binary_id', 'binary_name', 'binary_filename', 'binary_mediatype_name'], + {} + ); + for (const binary of binaries) { + const contractField = binary.binary_contract; + const contractId = Array.isArray(contractField) ? contractField[0] : contractField; + if (!contractId) { + continue; + } + const haystack = [ + binary.binary_id, + binary.binary_name, + binary.binary_filename, + binary.binary_mediatype_name, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + if (haystack.includes('stempel')) { + stempelByContract[contractId] = true; + } + } + } + + let projectFieldRecords = []; + if (includeProjectFields) { + projectFieldRecords = await this.orm.searchRead( + 'dss.advertisefields', + [ + ['project', '=', this.domain[0][2]], + ['contract', '=', false], + ['is_btn', '=', true], + ], + [ + 'id', + 'uuid', + 'btn_pos_x', + 'btn_pos_y', + 'btn_pos_w', + 'btn_pos_h', + 'btn_image_bin', + 'btn_fieldname', + 'feldname', + 'project', + ], + {} + ); + } + for (const rawRecord of records) { + const hasStempelBinary = !!stempelByContract[rawRecord.id]; + const personName = [rawRecord.client_short_vorname, rawRecord.client_short_name] + .filter(Boolean) + .join(' '); + const clientName = [rawRecord.client_short_company, personName] + .filter(Boolean) + .join('\n'); + const addressLines = [ + rawRecord.client_short_strasse, + [rawRecord.client_short_plz, rawRecord.client_short_ort].filter(Boolean).join(' '), + ] + .filter(Boolean) + .join('\n'); + const customerAddress = [clientName, addressLines] + .filter(Boolean) + .join('\n\n'); for (const feld of rawRecord.werbe_feld_selected) { const feldrecords = await this.orm.searchRead('dss.advertisefields', [['id','=',feld],['is_btn','=',true]], [],{}); if (feldrecords.length > 0) { var frecord = {} frecord.id=rawRecord.id+'_'+feldrecords[0].id; frecord.werbe_feld_selected_btn_pos_x=feldrecords[0].btn_pos_x*scale_faktor; - frecord.werbe_feld_selected_btn_pos_y=feldrecords[0].btn_pos_y*scale_faktor ; + frecord.werbe_feld_selected_btn_pos_y=feldrecords[0].btn_pos_y*scale_faktor; frecord.werbe_feld_selected_btn_pos_w=feldrecords[0].btn_pos_w*scale_faktor; frecord.werbe_feld_selected_btn_pos_h=feldrecords[0].btn_pos_h*scale_faktor; + frecord.original_btn_width=feldrecords[0].btn_pos_w; + frecord.original_btn_height=feldrecords[0].btn_pos_h; frecord.werbe_feld_selected_btn_img=feldrecords[0].btn_image_bin; frecord.uuid = rawRecord.uuid; frecord.contractid = rawRecord.id; @@ -83,12 +157,47 @@ export class ScreenViewModel { frecord.client_short_strasse = rawRecord.client_short_strasse; frecord.client_short_plz = rawRecord.client_short_plz; frecord.client_short_ort = rawRecord.client_short_ort; + frecord.has_stempel_binary = hasStempelBinary; + frecord.screenview_placeholder_text = hasStempelBinary && customerAddress + ? customerAddress + : feldrecords[0].btn_fieldname; + frecord.record_model = 'dss.contracts'; + frecord.record_id = rawRecord.id; frecord.key=rawRecord.id+'_'+feldrecords[0].id; newrecords[x] = frecord; x++; } } } + + for (const fieldRecord of projectFieldRecords) { + const projectField = { + id: `project_${fieldRecord.id}`, + key: `project_${fieldRecord.id}`, + uuid: fieldRecord.uuid, + contractid: false, + record_model: 'dss.advertisefields', + record_id: fieldRecord.id, + werbe_feld_selected_btn_pos_x: fieldRecord.btn_pos_x * scale_faktor, + werbe_feld_selected_btn_pos_y: fieldRecord.btn_pos_y * scale_faktor, + werbe_feld_selected_btn_pos_w: fieldRecord.btn_pos_w * scale_faktor, + werbe_feld_selected_btn_pos_h: fieldRecord.btn_pos_h * scale_faktor, + original_btn_width: fieldRecord.btn_pos_w, + original_btn_height: fieldRecord.btn_pos_h, + werbe_feld_selected_btn_img: fieldRecord.btn_image_bin, + shortwerbe_feld_selected: fieldRecord.btn_fieldname || fieldRecord.feldname, + screenview_placeholder_text: fieldRecord.btn_fieldname || fieldRecord.feldname, + client_short_company: '', + client_short_name: '', + client_short_vorname: '', + client_short_strasse: '', + client_short_plz: '', + client_short_ort: '', + has_stempel_binary: false, + }; + newrecords[x] = projectField; + x++; + } this.records = newrecords; this.recordsLength = newrecords.length; diff --git a/static/src/js/dss_screenview_renderer.js b/static/src/js/dss_screenview_renderer.js index a0e0970..0aab6b6 100755 --- a/static/src/js/dss_screenview_renderer.js +++ b/static/src/js/dss_screenview_renderer.js @@ -1,6 +1,6 @@ /** @odoo-module **/ import { useService } from "@web/core/utils/hooks"; -import { useEnv, Component, markup, onWillStart,onMounted,onWillUnmount, useRef, xml } from "@odoo/owl"; +import { useEnv, Component, markup, onWillStart, onWillUpdateProps, onMounted, onWillUnmount, useRef, xml } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { browser } from "@web/core/browser/browser"; @@ -18,6 +18,9 @@ export class ScreenViewRenderer extends Component { onWillStart(async () => { await this.willStart(this.props) }); + onWillUpdateProps(async (nextProps) => { + await this.willStart(nextProps); + }); } @@ -28,16 +31,21 @@ export class ScreenViewRenderer extends Component { // Event-Handler für den Click //_handleClick(event) { - handleClick() { + handleClick(event) { if (event.target.classList.contains("clickcontainer")) { console.log("Div clicked!", event.target); console.log(event.target.dataset.uuid); console.log(event.target.dataset.id); + const recordModel = event.target.dataset.model || 'dss.contracts'; + const recordId = Number.parseInt(event.target.dataset.id, 10); + if (!recordId) { + return; + } this.action.doAction({ type: 'ir.actions.act_window', - res_model: 'dss.contracts', - res_id: parseInt(event.target.dataset.id), - resId: parseInt(event.target.dataset.id), + res_model: recordModel, + res_id: recordId, + resId: recordId, views: [[false, "form"]], view_mode: "form", target: "current", @@ -45,23 +53,15 @@ export class ScreenViewRenderer extends Component { }} async render_Screen(props) { - var meins = ""; - var divele = ""; - var templates = [] - var templates = ['MainSection']; - console.log('1!!'+JSON.stringify(props)) - console.log(this); - console.log(this.max_h); - var maxx = props.records[0].max_w; - var maxy = props.records[0].max_h; - var width = window.innerWidth-140; - var scale_faktor = (width / maxx) - var height = Math.round((maxy+40) * scale_faktor) - props.screenwidth = width + 40 - props.scale = scale_faktor - props.screenheight = height + 100 - props.records.resId = false - console.log('1!! - '+props); + if (!props.records || !props.records.length) { + props.screenwidth = 0; + props.screenheight = 0; + return; + } + const frameMeta = props.records[0] || {}; + props.screenwidth = frameMeta.screenwidth || frameMeta.max_w || 0; + props.screenheight = frameMeta.screenheight || frameMeta.max_h || 0; + props.scale = frameMeta.scale || 1; } } diff --git a/static/src/js/dss_trigger_condition_view_blocks.js b/static/src/js/dss_trigger_condition_view_blocks.js old mode 100644 new mode 100755 index 153587b..ca90213 --- a/static/src/js/dss_trigger_condition_view_blocks.js +++ b/static/src/js/dss_trigger_condition_view_blocks.js @@ -739,6 +739,38 @@ export async function generateblocks(Blockly,pythonGenerator) { colour : 230, output : 'Number', }, + { + // The type is like the "class name" for your block. It is used to construct + // new instances. E.g. in the toolbox. + type: 'dss_value_number_multiply', + // The message defines the basic text of your block, and where inputs or + // fields will be inserted. + message0: '%1 (Zahl) * %2 (Zahl)', + args0: [ + // Each arg is associated with a %# in the message. + // This one gets substituted for %1. + { + // The type specifies the kind of input or field to be inserted. + type: 'input_value', + // The name allows you to reference the field and get its value. + name: 'Number1', + check: 'Number', + // The options are the values that will be shown in the dropdown. + + }, + { + // The type specifies the kind of input or field to be inserted. + type: 'input_value', + // The name allows you to reference the field and get its value. + name: 'Number2', + check: 'Number', + // The options are the values that will be shown in the dropdown. + + } + ], + colour : 230, + output : 'Number', + }, { // The type is like the "class name" for your block. It is used to construct // new instances. E.g. in the toolbox. @@ -1709,16 +1741,20 @@ export async function generateblocks(Blockly,pythonGenerator) { Blockly.Python.forBlock['dss_trigger_type_contract_feld_selector'] = function(block) { let code=''; let valuetable = block.getFieldValue('TriggerTable');; - let valueFeld = block.getFieldValue('TriggerFeld');; - console.log('Debug : ',block.parentBlock_.type); - if ((block.parentBlock_.type == 'dss_trigger_type')) { - code = '"'+String(valuetable) + '","'+String(valueFeld)+'"'; - } else { - if (window._isdataset) { - code = 'trigger.GetValueData(self,DataSet,'+valueFeld+')'; - } else { - code = 'trigger.GetValue(self,"' + String(valuetable) + '","'+String(valueFeld)+'")'; - } + let valueFeld = block.getFieldValue('TriggerFeld');; + if (block.parentBlock_ != null) { + if (block.parentBlock_.hasOwnProperty('type')) { + console.log('Debug : ',block.parentBlock_.type); + if ((block.parentBlock_.type == 'dss_trigger_type')) { + code = '"'+String(valuetable) + '","'+String(valueFeld)+'"'; + } else { + if (window._isdataset) { + code = 'trigger.GetValueData(self,DataSet,'+valueFeld+')'; + } else { + code = 'trigger.GetValue(self,"' + String(valuetable) + '","'+String(valueFeld)+'")'; + } + } + } } return code; }; @@ -2107,6 +2143,13 @@ export async function generateblocks(Blockly,pythonGenerator) { let code = '('+innerstatement_Number1+' + '+innerstatement_Number2+')'; return code; } + + Blockly.Python.forBlock['dss_value_number_multiply'] = function(block) { + let innerstatement_Number1 = Blockly.Python.statementToCode(block, 'Number1'); + let innerstatement_Number2 = Blockly.Python.statementToCode(block, 'Number2'); + let code = '('+innerstatement_Number1+' * '+innerstatement_Number2+')'; + return code; + } Blockly.Python.forBlock['dss_data_set_var'] = function(block) { let name = block.getFieldValue('name'); diff --git a/static/src/js/dss_trigger_condition_view_controller.js b/static/src/js/dss_trigger_condition_view_controller.js index 07d4bfe..e5cf2f9 100755 --- a/static/src/js/dss_trigger_condition_view_controller.js +++ b/static/src/js/dss_trigger_condition_view_controller.js @@ -174,15 +174,13 @@ export class Trigger_Condition_View_Controller extends Component { // ); onWillStart(async () => { - await loadJS("https://unpkg.com/blockly/blockly_compressed.js"); - //await loadJS("./src/js/blockly/blockly_compressed.js"); - await loadJS("https://unpkg.com/blockly/blocks_compressed.js"); - await loadJS("https://unpkg.com/blockly/javascript_compressed.js"); - await loadJS("https://unpkg.com/blockly/python_compressed.js"); - //await loadJS("https://unpkg.com/blockly/msg/js/de.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/blockly_compressed.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/blocks_compressed.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/javascript_compressed.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/python_compressed.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/msg/en.js"); await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/msg/de.js"); //await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/plugins/block-plus-minus/src/list_create.js"); - await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/msg/de.js"); await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/ace/src-noconflict/ace.js"); await this.model.load(); }); @@ -191,6 +189,62 @@ export class Trigger_Condition_View_Controller extends Component { //await this.model.load(); //alert('mounted'); console.log(Blockly); + + // Some Blockly message bundles miss newer ARIA keys. Add safe fallbacks + // and guard toolbox aria-label generation so inject() cannot crash. + const ensureBlocklyAriaFallbacks = () => { + if (!Blockly || !Blockly.Msg) { + return; + } + if (!Blockly.Msg.__dssMsgProxyApplied) { + const rawMsg = Blockly.Msg; + Blockly.Msg = new Proxy(rawMsg, { + get(target, prop, receiver) { + if (prop === "__dssMsgProxyApplied") { + return true; + } + const value = Reflect.get(target, prop, receiver); + return value === undefined || value === null ? String(prop) : value; + }, + }); + } + const defaults = { + WORKSPACE_ARIA_LABEL: "Blockly Workspace", + COLLAPSE_BLOCK: "Collapse block", + EXPAND_BLOCK: "Expand block", + COLLAPSE_ALL: "Collapse all blocks", + EXPAND_ALL: "Expand all blocks", + }; + Object.keys(defaults).forEach((key) => { + if (!Blockly.Msg[key]) { + Blockly.Msg[key] = defaults[key]; + } + }); + + const patchCategory = (proto) => { + if (!proto || typeof proto.updateAriaLabel !== "function" || proto.__dssAriaPatched) { + return; + } + const original = proto.updateAriaLabel; + proto.updateAriaLabel = function () { + try { + return original.apply(this, arguments); + } catch (err) { + const fallbackName = (this.toolboxItemDef_ && this.toolboxItemDef_.name) || "Toolbox"; + if (this.htmlDiv_) { + this.htmlDiv_.setAttribute("aria-label", fallbackName); + } + console.warn("Fallback aria label used for toolbox category", err); + } + }; + proto.__dssAriaPatched = true; + }; + + patchCategory(Blockly.ToolboxCategory && Blockly.ToolboxCategory.prototype); + patchCategory(Blockly.CollapsibleToolboxCategory && Blockly.CollapsibleToolboxCategory.prototype); + }; + + ensureBlocklyAriaFallbacks(); class dss_trigger_type_contract_selector_Field extends Blockly.Field { constructor(value, validator) { super(value, validator); @@ -721,10 +775,15 @@ export class Trigger_Condition_View_Controller extends Component { this.setOnChange(async function(changeEvent) { // This function is calledwhen the block changes. // You can use it to update the block's appearance or behavior. - if ((changeEvent.name == "DatasatzTabelle") && (changeEvent.type == "change")) { + if ( + (changeEvent.name == "DatasatzTabelle") && + (changeEvent.type == "change") && + (changeEvent.element == "field") && + (changeEvent.blockId == this.id) + ) { //console.log('onchange: '+JSON.stringify(changeEvent)); var Block = this.workspace.getBlockById(changeEvent.blockId); - setBlockValue_dataset(this); + await setBlockValue_dataset(Block); }; }); }); @@ -735,10 +794,15 @@ export class Trigger_Condition_View_Controller extends Component { this.setOnChange(async function(changeEvent) { // This function is called when the block changes. // You can use it to update the block's appearance or behavior. - if ((changeEvent.name == "TriggerTable") && (changeEvent.type == "change")) { + if ( + (changeEvent.name == "TriggerTable") && + (changeEvent.type == "change") && + (changeEvent.element == "field") && + (changeEvent.blockId == this.id) + ) { //console.log('onchange: '+JSON.stringify(changeEvent)); var Block = this.workspace.getBlockById(changeEvent.blockId); - setBlockValue(Block); + await setBlockValue(Block); }; }); }); @@ -977,6 +1041,10 @@ export class Trigger_Condition_View_Controller extends Component { kind: 'block', type: 'dss_value_number_add' }, + { + kind: 'block', + type: 'dss_value_number_multiply' + }, ] }, @@ -1078,7 +1146,15 @@ export class Trigger_Condition_View_Controller extends Component { // You can use it to update the block's appearance or behavior. //console.log('change: ',changeEvent); if (changeEvent.type == "change") { - document.getElementById("isCodeUptodate").checked=false; + const autoCodeCheckbox = document.getElementById("isautocodecreate"); + if (autoCodeCheckbox && autoCodeCheckbox.checked) { + Blockly.Python.init(Blockly.getMainWorkspace()); + const autoCode = Blockly.Python.workspaceToCode(Blockly.getMainWorkspace()); + document.getElementById("codeArea").value = autoCode; + document.getElementById("isCodeUptodate").checked = true; + } else { + document.getElementById("isCodeUptodate").checked=false; + } if ((changeEvent.name == "TriggerTable") && (changeEvent.element == "field")) { //console.log('change - Rebuild Data : ',changeEvent); var Block = this.props.workspace.getBlockById(changeEvent.blockId); diff --git a/static/src/js/dss_trigger_condition_view_helper.js b/static/src/js/dss_trigger_condition_view_helper.js old mode 100644 new mode 100755 index 94047d9..434a71d --- a/static/src/js/dss_trigger_condition_view_helper.js +++ b/static/src/js/dss_trigger_condition_view_helper.js @@ -1,6 +1,52 @@ /** @odoo-module **/ +function applyDropdownOptions(fieldBlock, options) { + if (!fieldBlock) { + return; + } + + if (typeof fieldBlock.setOptions === 'function') { + fieldBlock.setOptions(function() { + return options; + }); + notifyProgrammaticFieldUpdate(fieldBlock); + return; + } + + // Blockly compatibility fallback (older/newer builds without setOptions). + if ('menuGenerator_' in fieldBlock) { + fieldBlock.menuGenerator_ = options; + if (typeof fieldBlock.getOptions === 'function') { + fieldBlock.getOptions(false); + } + notifyProgrammaticFieldUpdate(fieldBlock); + } +} + +function notifyProgrammaticFieldUpdate(fieldBlock) { + if (!fieldBlock || typeof fieldBlock.getSourceBlock !== 'function') { + return; + } + + const block = fieldBlock.getSourceBlock(); + if (!block) { + return; + } + + const BlocklyRef = (typeof Blockly !== 'undefined') + ? Blockly + : ((typeof window !== 'undefined' && window.Blockly) ? window.Blockly : null); + if (!BlocklyRef || !BlocklyRef.Events || typeof BlocklyRef.Events.BlockChange !== 'function') { + return; + } + + const fieldName = fieldBlock.name || 'field'; + const mutationTag = 'options_updated'; + BlocklyRef.Events.fire(new BlocklyRef.Events.BlockChange(block, 'mutation', fieldName, '', mutationTag)); +} + + export async function setBlockValue(Block) { var TableBlock = Block.getField('TriggerTable'); @@ -17,14 +63,12 @@ export async function setBlockValue(Block) { 'tablename': TableValue, }, }); - FeldBlock.setOptions(function() { - var options = []; - records.forEach(element => { - //console.log('Element: '+JSON.stringify(element)); - options.push([String(element[3]),String(element[0])]); - }); - return options; - }); + var options = []; + records.forEach(element => { + //console.log('Element: '+JSON.stringify(element)); + options.push([String(element[3]),String(element[0])]); + }); + applyDropdownOptions(FeldBlock, options); }; } @@ -45,14 +89,12 @@ export async function setBlockValue_dataset(Block) { 'tablename': TableValue, }, }); - FeldBlock.setOptions(function() { - var options = []; - records.forEach(element => { - //console.log('Element: '+JSON.stringify(element)); - options.push([String(element[3]),String(element[0])]); - }); - return options; - }); + var options = []; + records.forEach(element => { + //console.log('Element: '+JSON.stringify(element)); + options.push([String(element[3]),String(element[0])]); + }); + applyDropdownOptions(FeldBlock, options); }; } @@ -67,14 +109,12 @@ export async function setBlock_MarkerValue(Block) { if (records && records.length > 0) { // Wandelt das erste gefundene Objekt in JSON um und gibt es aus //console.log('Foundtrigger JSON:', JSON.stringify(records[0])); - FeldBlock.setOptions(function() { - var options = []; - records.forEach(element => { - //console.log('Element: '+JSON.stringify(element)); - options.push([String(element['markername']),String(element['id'])]); - }) ; - return options; - }); + var options = []; + records.forEach(element => { + //console.log('Element: '+JSON.stringify(element)); + options.push([String(element['markername']),String(element['id'])]); + }) ; + applyDropdownOptions(FeldBlock, options); } else { console.log('no marker records Found'); }; @@ -90,14 +130,12 @@ export async function setBlock_MailValues(Block) { if (records && records.length > 0) { // Wandelt das erste gefundene Objekt in JSON um und gibt es aus // console.log('Foundtrigger JSON:', JSON.stringify(records[0])); - FeldBlock.setOptions(function() { - var options = []; - records.forEach(element => { - //console.log('Element: '+JSON.stringify(element)); - options.push([String(element['name']),String(element['id'])]); - }) ; - return options; - }); + var options = []; + records.forEach(element => { + //console.log('Element: '+JSON.stringify(element)); + options.push([String(element['name']),String(element['id'])]); + }) ; + applyDropdownOptions(FeldBlock, options); } else { console.log('no mail.template records Found'); }; @@ -114,14 +152,12 @@ export async function setBlock_PartnerValue(Block) { if (records && records.length > 0) { // Wandelt das erste gefundene Objekt in JSON um und gibt es aus // console.log('Foundtrigger JSON:', JSON.stringify(records[0])); - FeldBlock.setOptions(function() { - var options = []; - records.forEach(element => { - //console.log('Element: '+JSON.stringify(element)); - options.push([String(element['commercial_company_name'])+', '+String(element['name']),String(element['id'])]); - }) ; - return options; - }); + var options = []; + records.forEach(element => { + //console.log('Element: '+JSON.stringify(element)); + options.push([String(element['commercial_company_name'])+', '+String(element['name']),String(element['id'])]); + }) ; + applyDropdownOptions(FeldBlock, options); } else { console.log('no marker records Found'); }; @@ -137,14 +173,12 @@ export async function setBlock_MediaTypesValue(Block) { if (records && records.length > 0) { // Wandelt das erste gefundene Objekt in JSON um und gibt es aus // console.log('Foundtrigger JSON:', JSON.stringify(records[0])); - FeldBlock.setOptions(function() { - var options = []; - records.forEach(element => { - //console.log('Element: '+JSON.stringify(element)); - options.push([String(element['medianame'])+', '+String(element['mediatype']),String(element['id'])]); - }) ; - return options; - }); + var options = []; + records.forEach(element => { + //console.log('Element: '+JSON.stringify(element)); + options.push([String(element['medianame'])+', '+String(element['mediatype']),String(element['id'])]); + }) ; + applyDropdownOptions(FeldBlock, options); } else { console.log('Media Type records Found'); }; @@ -162,14 +196,12 @@ export async function setBlock_DataTableValue(Block) { if (records && records.length > 0) { // Wandelt das erste gefundene Objekt in JSON um und gibt es aus console.log('Foundtrigger JSON:', JSON.stringify(records[0])); - FeldBlock.setOptions(function() { - var options = []; - records.forEach(element => { - //console.log('Element: '+JSON.stringify(element)); - options.push([String(element['name'])+' ('+String(element['model'])+')',String(element['id'])]); - }) ; - return options; - }); + var options = []; + records.forEach(element => { + //console.log('Element: '+JSON.stringify(element)); + options.push([String(element['name'])+' ('+String(element['model'])+')',String(element['id'])]); + }) ; + applyDropdownOptions(FeldBlock, options); } else { console.log('Model records Found'); }; @@ -249,12 +281,10 @@ export async function generateaction(rpc,mainWorkspace,action_element,nextconnec actionblock_emailsubblock_1.initSvg(); actionblock_emailsubblock_1.render(); var field = actionblock_emailsubblock_1.getField('PartnerFeld') - field.setOptions(function() { - var options = []; - options.push([String('Partner auswählen ...'),String(0)]); - options.push([String(action_element.email_fix_email),String(action_element.email_fix_email)]); - return options; - }) ; + var options = []; + options.push([String('Partner auswaehlen ...'),String(0)]); + options.push([String(action_element.email_fix_email),String(action_element.email_fix_email)]); + applyDropdownOptions(field, options); //await setBlock_PartnerValue(actionblock_emailsubblock_1); field.setValue(String(action_element.email_fix_email)); } else { diff --git a/static/src/js/dss_trigger_condition_view_renderer.js b/static/src/js/dss_trigger_condition_view_renderer.js index b71df55..5ce4043 100755 --- a/static/src/js/dss_trigger_condition_view_renderer.js +++ b/static/src/js/dss_trigger_condition_view_renderer.js @@ -18,15 +18,13 @@ export class Trigger_Condition_View_Renderer extends Component { //alert('render '+JSON.stringify(this.props)) onWillStart(async () => { - await loadJS("https://unpkg.com/blockly/blockly_compressed.js"); - //await loadJS("./src/js/blockly/blockly_compressed.js"); - await loadJS("https://unpkg.com/blockly/blocks_compressed.js"); - await loadJS("https://unpkg.com/blockly/javascript_compressed.js"); - await loadJS("https://unpkg.com/blockly/python_compressed.js"); - //await loadJS("https://unpkg.com/blockly/msg/js/de.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/blockly_compressed.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/blocks_compressed.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/javascript_compressed.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/python_compressed.js"); + await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/msg/en.js"); await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/msg/de.js"); //await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/plugins/block-plus-minus/src/list_create.js"); - await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/blockly/msg/de.js"); //await loadJS("https://verwaltung.logumedia.de/DigitalSignage/static/src/js/ace/src-noconflict/ace.js"); await this.willStart(this.props) }); @@ -120,26 +118,48 @@ export class Trigger_Condition_View_Renderer extends Component { async handleSaveClick() { console.log('Save Click',this.props.workspace); - Blockly.Events.disable() - var blocklySave = Blockly.serialization.workspaces.save(Blockly.getMainWorkspace()); - console.log('Save : ',blocklySave); - var selectedValue = document.getElementById("SelectName").value - var selectedAktiv = document.getElementById("isactive").checked - const triggersy = await rpc.query({ - model: 'dss.triggertypes', - method: 'UpdateBlockTrigger', - kwargs: { - 'triggername': selectedValue, - 'blockcode': JSON.stringify(blocklySave), - 'blockcommand': document.getElementById("codeArea").value, - 'blockcommand_uptodate': document.getElementById("isCodeUptodate").checked, - 'triggertable': document.getElementById("Datatable").value, - 'triggeraktiv': selectedAktiv, - } - }); - Blockly.Events.enable() - Blockly.getMainWorkspace().clear(); - Blockly.serialization.workspaces.load(blocklySave, Blockly.getMainWorkspace()); + Blockly.Events.disable(); + try { + var blocklySave = Blockly.serialization.workspaces.save(Blockly.getMainWorkspace()); + console.log('Save : ',blocklySave); + var selectedValue = document.getElementById("SelectName").value + var selectedAktiv = document.getElementById("isactive").checked + var autoCodeElement = document.getElementById("isautocodecreate"); + var selectedAutoCode = autoCodeElement ? autoCodeElement.checked : false; + const saveKwargs = { + 'triggername': selectedValue, + 'blockcode': JSON.stringify(blocklySave), + 'blockcommand': document.getElementById("codeArea").value, + 'blockcommand_uptodate': document.getElementById("isCodeUptodate").checked, + 'triggertable': document.getElementById("Datatable").value, + 'triggeraktiv': selectedAktiv, + 'triggerautocode': selectedAutoCode, + }; + + try { + await rpc.query({ + model: 'dss.triggertypes', + method: 'UpdateBlockTrigger', + kwargs: saveKwargs, + }); + } catch (error) { + // Backward compatibility: old server code may not yet accept triggerautocode. + // Retry once without the new parameter regardless of error shape/text. + console.warn('Primary trigger save failed, retrying without triggerautocode', error); + const fallbackKwargs = { ...saveKwargs }; + delete fallbackKwargs.triggerautocode; + await rpc.query({ + model: 'dss.triggertypes', + method: 'UpdateBlockTrigger', + kwargs: fallbackKwargs, + }); + } + + Blockly.getMainWorkspace().clear(); + Blockly.serialization.workspaces.load(blocklySave, Blockly.getMainWorkspace()); + } finally { + Blockly.Events.enable(); + } } @@ -244,18 +264,22 @@ export class Trigger_Condition_View_Renderer extends Component { }); console.log('Selected value from dropdown:', selectedValue,' - ',triggersy); const mainWorkspace = Blockly.getMainWorkspace(); - - if (mainWorkspace) { - console.log('Workspace clear '+mainWorkspace); - mainWorkspace.clear(); + if (!mainWorkspace || typeof mainWorkspace.clear !== "function") { + console.warn("Blockly workspace is not ready yet. Change event skipped."); + return this.props.records; } + console.log('Workspace clear '+mainWorkspace); + mainWorkspace.clear(); + this.props.isActive = (triggersy.trigger_active) ? true : false this.props.isblockonly = (triggersy.trigger_is_block_config) ? true : false this.props.isCodeUptodate=triggersy.trigger_block_command_uptodate + this.props.isAutoCodeCreate=(triggersy.trigger_auto_code_create) ? true : false document.getElementById("isactive").checked = this.props.isActive; document.getElementById("isblockonly").checked = this.props.isblockonly + document.getElementById("isautocodecreate").checked = this.props.isAutoCodeCreate document.getElementById("Save").removeAttribute("disabled"); document.getElementById("Saveas").removeAttribute("disabled"); @@ -478,6 +502,10 @@ export class Trigger_Condition_View_Renderer extends Component { } else { Blockly.Events.disable(); const workspace = Blockly.getMainWorkspace(); + if (!workspace || typeof workspace.clear !== "function") { + console.warn("Blockly workspace is not ready yet. Block config load skipped."); + return this.props.records; + } console.log('Load Blockly : ',workspace); workspace.clear(); var blockload = triggersy.trigger_block_raw_config; diff --git a/static/src/js/info.txt b/static/src/js/info.txt old mode 100644 new mode 100755 diff --git a/static/src/js/kilead_hook.js b/static/src/js/kilead_hook.js old mode 100644 new mode 100755 diff --git a/static/src/js/navbar_ai_button.js b/static/src/js/navbar_ai_button.js new file mode 100755 index 0000000..4151f3a --- /dev/null +++ b/static/src/js/navbar_ai_button.js @@ -0,0 +1,298 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { Dialog } from "@web/core/dialog/dialog"; +import { patch } from "@web/core/utils/patch"; +import { useService } from "@web/core/utils/hooks"; +import { NavBar } from "@web/webclient/navbar/navbar"; + +const { Component, useEffect, useRef, useState } = owl; + +class AIAgentDialog extends Component { + setup() { + this.rpcService = useService("rpc"); + this.actionService = useService("action"); + this.rootRef = useRef("root"); + this.openTableTarget = this.openTableTarget.bind(this); + this.openTableUrl = this.openTableUrl.bind(this); + this.state = useState({ + draft: "", + nextId: 2, + sending: false, + fullscreen: false, + prompts: [], + messages: [ + { + id: 1, + author: "ai", + text: _t("Hallo, ich bin dein AI Agent. Wie kann ich dir helfen?"), + }, + ], + }); + + useEffect( + (isFullscreen) => { + const modalDialog = this.rootRef.el && this.rootRef.el.closest(".modal-dialog"); + if (!modalDialog) { + return; + } + modalDialog.classList.toggle("o_ai_agent_fullscreen_dialog", isFullscreen); + return () => { + modalDialog.classList.remove("o_ai_agent_fullscreen_dialog"); + }; + }, + () => [this.state.fullscreen] + ); + + this.loadPromptHistory(); + } + + async loadPromptHistory() { + try { + const history = await this.rpcService("/digitalsignage/ai_agent/history", { limit: 20 }); + if (history && history.items && history.items.length) { + const mapped = history.items.map((item) => ({ + id: this.state.nextId++, + author: item.role === "assistant" ? "ai" : "user", + text: item.content || "", + })); + if (mapped.length) { + this.state.messages = mapped; + } + } + + const promptResult = await this.rpcService("/digitalsignage/ai_agent/prompts", { limit: 20 }); + this.state.prompts = (promptResult && promptResult.prompts) || []; + } catch (error) { + // Keep default welcome message if history/prompts cannot be loaded. + } + } + + updateDraft(ev) { + this.state.draft = ev.target.value; + } + + onKeydown(ev) { + if (ev.key === "Enter" && !ev.shiftKey) { + ev.preventDefault(); + this.sendMessage(); + } + } + + sendMessage() { + return this._sendMessage(); + } + + toggleFullscreen() { + this.state.fullscreen = !this.state.fullscreen; + } + + selectPrompt(ev) { + const value = ev.target.value || ""; + if (value) { + this.state.draft = value; + } + } + + async _sendMessage() { + if (this.state.sending) { + return; + } + + const text = this.state.draft.trim(); + if (!text) { + return; + } + + this.state.messages.push({ + id: this.state.nextId++, + author: "user", + text, + }); + this.state.draft = ""; + this.state.sending = true; + + try { + const payloadMessages = [ + { + role: "user", + content: text, + }, + ]; + const result = await this.rpcService("/digitalsignage/ai_agent/chat", { + messages: payloadMessages, + }); + + const openedUrl = result && result.open_table && result.open_table.url; + const openAction = result && result.open_table && result.open_table.open_action; + const openTable = result && result.open_table; + + const replyText = (result && result.reply) || _t("Keine Antwort erhalten."); + const replyWithDebugUrl = openedUrl + ? replyText + "\n\nDebug URL: " + openedUrl + : replyText; + + this.state.messages.push({ + id: this.state.nextId++, + author: "ai", + text: replyWithDebugUrl, + openUrl: openedUrl || null, + openAction: openAction || null, + openTable: openTable || null, + openLabel: (openTable && openTable.open_label) || (openedUrl || openAction ? _t("Liste öffnen") : null), + }); + + // For explicit navigation tools (e.g. open_contract_form), open immediately. + if (openTable && openTable.auto_open) { + await this.openTableTarget(openTable || null, openAction || null, openedUrl || null); + } + } catch (error) { + const backendMessage = + (error && error.data && (error.data.message || error.data.debug)) || + (error && error.message) || + null; + this.state.messages.push({ + id: this.state.nextId++, + author: "ai", + text: + backendMessage || + _t("Fehler beim Abruf der AI-Antwort. Bitte pruefe die OpenAI-Konfiguration."), + }); + } finally { + this.state.sending = false; + } + } + + _openFilteredTable(openTable) { + const targetUrlFromBackend = openTable && openTable.url; + const model = openTable && openTable.model; + const recordIds = (openTable && openTable.record_ids) || []; + const domain = recordIds.length ? [["id", "in", recordIds]] : (openTable && openTable.domain) || []; + const actionId = openTable && openTable.action_id; + const menuId = openTable && openTable.menu_id; + const viewType = (openTable && openTable.view_type) || "tree"; + if (!model) { + return; + } + + const params = new URLSearchParams(); + if (actionId) { + params.set("action", String(actionId)); + } + if (menuId) { + params.set("menu_id", String(menuId)); + } + params.set("model", model); + params.set("view_type", viewType); + params.set("domain", JSON.stringify(domain)); + if (recordIds.length) { + params.set("record_ids", JSON.stringify(recordIds)); + } + + const useBackendUrl = !recordIds.length && (!domain || !domain.length); + const rawTargetUrl = useBackendUrl && targetUrlFromBackend ? targetUrlFromBackend : ("/web#" + params.toString()); + const targetUrl = rawTargetUrl.startsWith("http") + ? rawTargetUrl + : window.location.origin + rawTargetUrl; + window.open(targetUrl, "_blank", "noopener,noreferrer"); + } + + openTableUrl(url) { + if (!url) { + return; + } + const targetUrl = url.startsWith("http") ? url : window.location.origin + url; + window.open(targetUrl, "_blank", "noopener,noreferrer"); + if (this.props && this.props.close) { + this.props.close(); + } + } + + openTableTarget(openTable, openAction, openUrl) { + if (openAction) { + const result = this.actionService.doAction(openAction); + if (this.props && this.props.close) { + this.props.close(); + } + return result; + } + if (openTable && (openTable.record_ids || openTable.domain || openTable.url)) { + const result = this._openFilteredTable(openTable); + if (this.props && this.props.close) { + this.props.close(); + } + return result; + } + if (openUrl) { + return this.openTableUrl(openUrl); + } + } +} + +AIAgentDialog.components = { Dialog }; +AIAgentDialog.template = "DigitalSignage.AIAgentDialog"; + +patch(NavBar.prototype, "DigitalSignage.NavBarAIButton", { + setup() { + this._super.apply(this, arguments); + this.dialogService = useService("dialog"); + this.actionService = useService("action"); + this.rpcService = useService("rpc"); + this.notificationService = useService("notification"); + this.contractGoInput = useRef("contractGoInput"); + }, + + onClickAIButton() { + this.dialogService.add(AIAgentDialog, {}); + }, + + onContractGoKeydown(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + this.onContractGoClick(); + } + }, + + async onContractGoClick() { + const inputEl = this.contractGoInput && this.contractGoInput.el; + const number = inputEl ? (inputEl.value || "").trim() : ""; + if (!number) { + this.notificationService.add(_t("Bitte eine Nummer eingeben."), { type: "warning" }); + return; + } + try { + const result = await this.rpcService("/digitalsignage/ai_agent/open_contract", { + contract_auto_id: number, + }); + if (!result || !result.found) { + const msg = (result && (result.message || result.error)) || _t("Kein Vertrag/Projekt gefunden."); + const type = result && result.error ? "danger" : "warning"; + this.notificationService.add(msg, { type }); + console.warn("[ContractGo] Vertrag/Projekt nicht gefunden:", number); + return; + } + if (result.message) { + this.notificationService.add(result.message, { type: "success" }); + } + const openAction = result.open_table && result.open_table.open_action; + const openUrl = result.open_table && result.open_table.url; + if (openAction) { + await this.actionService.doAction(openAction); + if (inputEl) { inputEl.value = ""; } + return; + } + if (openUrl) { + const targetUrl = openUrl.startsWith("http") + ? openUrl + : window.location.origin + openUrl; + if (inputEl) { inputEl.value = ""; } + window.location.href = targetUrl; + return; + } + this.notificationService.add(_t("Keine Oeffnungsaktion vorhanden."), { type: "warning" }); + } catch (e) { + this.notificationService.add(_t("Fehler beim Oeffnen des Eintrags."), { type: "danger" }); + console.error("[ContractGo] Fehler beim Oeffnen des Vertrags:", e); + } + }, +}); diff --git a/static/src/js/phone_center_action.js b/static/src/js/phone_center_action.js new file mode 100755 index 0000000..5bf7acf --- /dev/null +++ b/static/src/js/phone_center_action.js @@ -0,0 +1,1297 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { Component, onWillStart, useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +function buildSafeUrl(mode, rawValue) { + const value = (rawValue || "").trim(); + if (!value) { + return "about:blank"; + } + if (mode === "maps") { + return `https://www.google.com/maps?q=${encodeURIComponent(value)}&output=embed`; + } + if (/^https?:\/\//i.test(value)) { + return value; + } + if (value.startsWith("//")) { + return `https:${value}`; + } + return `https://${value}`; +} + +function buildWebsiteUrl(rawValue) { + const value = (rawValue || "").trim(); + if (!value) { + return ""; + } + if (/^https?:\/\//i.test(value)) { + return value; + } + if (value.startsWith("//")) { + return `https:${value}`; + } + return `https://${value}`; +} + +function preferHttps(urlValue) { + const url = String(urlValue || "").trim(); + if (!url) { + return ""; + } + if (window.location.protocol === "https:" && /^http:\/\//i.test(url)) { + return url.replace(/^http:\/\//i, "https://"); + } + return url; +} + +function formatDate(date) { + return date.toISOString().slice(0, 10); +} + +function formatDateTimeLocal(date) { + const pad = (value) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function buildPhoneLinks(rawPhone) { + const raw = String(rawPhone || ""); + const chunks = raw.split(/[\n,;/|]+/).map((item) => item.trim()).filter(Boolean); + const uniq = []; + const seen = new Set(); + for (const chunk of chunks) { + const normalized = chunk.replace(/[^\d+]/g, ""); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + uniq.push({ + label: chunk, + href: `tel:${normalized}`, + }); + } + return uniq; +} + +function buildBadContactFallback(reason, contact) { + const company = (contact && contact.company) || "-"; + const city = (contact && contact.city) || "-"; + const phone = (contact && contact.phone) || "-"; + return { + summary: `Einschaetzung: ${reason}. Kontakt: ${company} (${city}), Telefon ${phone}. Empfehlung: zuerst Wiedervorlage oder weitere Recherche, sonst verwerfen.`, + actions: ["reschedule", "research", "keep", "discard"], + }; +} + +export class PhoneCenterAction extends Component { + setup() { + this.orm = useService("orm"); + this.notification = useService("notification"); + const now = new Date(); + + this.state = useState({ + projects: [], + contacts: [], + writers: [], + events: [], + profile: { + viewMode: "callcenter", + viewModeLabel: "Callcenter", + canSeeAllProjects: false, + assignedProjectIds: [], + }, + projectId: null, + contactId: null, + writerId: null, + centerViewMode: "research", + calendarProvider: "internal", + googleCalendarUrl: "https://calendar.google.com/calendar/embed?ctz=Europe%2FBerlin", + calendarYear: now.getFullYear(), + calendarMonth: now.getMonth(), + calendarDays: [], + calendarTitle: "", + selectedDate: null, + selectedEventId: null, + editMode: false, + contactInfo: { + company: "", + name: "", + phone: "", + email: "", + website: "", + city: "", + address: "", + rating: "", + source: "", + verified: false, + verifiedAt: "", + analysis: "", + }, + classification: "", + classificationNote: "", + contactWebsiteHref: "", + contactPhones: [], + showBadContactModal: false, + badContactReason: "", + badContactSummary: "", + badContactActions: [], + isAnalyzingBadContact: false, + showNoInterestModal: false, + noInterestPartner: "nicht_bekannt", + browserMode: "website", + browserQuery: "", + browserUrl: "about:blank", + locationQuery: "", + locationFound: false, + locationLat: null, + locationLng: null, + radiusKm: 20, + radiusCirclePx: 90, + industryQuery: "", + minEntriesForResearch: 8, + companyProvider: "google_places", + aiProvider: "openai", + companyOptions: [], + hasSearchedCompanies: false, + searchBlocked: false, + radiusMax: 2000, + projectQueries: [], + selectedQueryId: null, + showNewQueryForm: false, + selectedQueryType: "query", + selectedCompanyLineId: null, + selectedCompanyOptionId: null, + queryModelAvailable: true, + queryModelWarningShown: false, + mapOverlayUrl: "", + showMapOverlay: false, + showRescheduleModal: false, + rescheduleAt: "", + rescheduleDays: 7, + rescheduleReason: "", + eventForm: { + title: "", + startDate: "", + endDate: "", + note: "", + }, + }); + + onWillStart(async () => { + await this.loadProfile(); + await this.loadSearchConfig(); + await this.loadProjects(); + this.rebuildCalendar(); + }); + } + + async loadSearchConfig() { + const cfg = await this.orm.call("dss.settings", "phonecenter_get_search_config", []); + this.state.minEntriesForResearch = Number(cfg.min_entries || 8); + this.state.companyProvider = cfg.company_provider || "google_places"; + this.state.aiProvider = cfg.ai_provider || "openai"; + this.state.radiusMax = Number(cfg.radius_max || 2000); + } + + async loadProfile() { + const profile = await this.orm.call("res.users", "get_phone_center_profile", []); + const labels = { + callcenter: "Callcenter", + projectmanagement: "Projektmanagement", + aussendienst: "Aussendienst", + administrator: "Administrator", + }; + this.state.profile = { + viewMode: profile.view_mode || "callcenter", + viewModeLabel: labels[profile.view_mode] || "Callcenter", + canSeeAllProjects: Boolean(profile.can_see_all_projects), + assignedProjectIds: profile.assigned_project_ids || [], + }; + } + + async loadProjects() { + const domain = this.state.profile.canSeeAllProjects + ? [] + : [["id", "in", this.state.profile.assignedProjectIds]]; + + this.state.projects = await this.orm.searchRead( + "dss.projects", + domain, + ["projektname", "standort_ort"], + { order: "projektname asc", limit: 300 } + ); + } + + _resetContactState() { + this.state.contactInfo = { + company: "", + name: "", + phone: "", + email: "", + website: "", + city: "", + address: "", + rating: "", + source: "", + verified: false, + verifiedAt: "", + analysis: "", + }; + this.state.contactWebsiteHref = ""; + this.state.contactPhones = []; + this.state.showNoInterestModal = false; + this.state.noInterestPartner = "nicht_bekannt"; + this.state.badContactSummary = ""; + this.state.badContactActions = []; + this.state.badContactReason = ""; + this.state.showBadContactModal = false; + } + + async onProjectChange(ev) { + const projectId = Number(ev.target.value) || null; + this.state.projectId = projectId; + this.state.contactId = null; + this.state.writerId = null; + this.state.centerViewMode = "research"; + this.state.calendarProvider = "internal"; + this.state.googleCalendarUrl = "https://calendar.google.com/calendar/embed?ctz=Europe%2FBerlin"; + this.state.contacts = []; + this.state.writers = []; + this.state.events = []; + this.resetEventForm(); + this._resetContactState(); + this.state.companyOptions = []; + this.state.hasSearchedCompanies = false; + this.state.searchBlocked = false; + this.state.projectQueries = []; + this.state.selectedQueryId = null; + this.state.selectedQueryType = "query"; + this.state.showNewQueryForm = false; + this.state.selectedCompanyLineId = null; + this.state.selectedCompanyOptionId = null; + this.state.mapOverlayUrl = ""; + this.state.showMapOverlay = false; + + if (!projectId) { + this.rebuildCalendar(); + return; + } + + await this.loadProjectQueries(projectId); + + const contracts = await this.orm.searchRead( + "dss.contracts", + [["project", "=", projectId]], + [ + "contract_name", + "client", + "contact_company_name", + "contact_name", + "contact_telefon", + "contact_email", + "contact_web", + "contact_city", + "client_short_website", + "contract_writer", + "start_date", + "calc_end_date", + "contract_remark", + ], + { order: "id desc", limit: 500 } + ); + + this.state.contacts = contracts.map((contract) => ({ + id: contract.id, + label: contract.contract_name || `Vertrag ${contract.id}`, + source: contract, + })); + + if (!this.state.profile.canSeeAllProjects && contracts.length) { + this.applyContactInfo(contracts[0]); + } + + const writerMap = new Map(); + for (const contract of contracts) { + if (contract.contract_writer && contract.contract_writer[0]) { + writerMap.set(contract.contract_writer[0], { + id: contract.contract_writer[0], + name: contract.contract_writer[1], + }); + } + } + this.state.writers = [...writerMap.values()]; + if (this.state.writers.length) { + this.state.writerId = this.state.writers[0].id; + this.loadEventsFromContracts(); + await this.updateGoogleCalendarUrl(); + } else { + this.rebuildCalendar(); + await this.updateGoogleCalendarUrl(); + } + } + + setCenterViewResearch() { + this.state.centerViewMode = "research"; + } + + setCenterViewCalendar() { + this.state.centerViewMode = "calendar"; + } + + async onCalendarProviderChange(ev) { + this.state.calendarProvider = ev.target.value === "google" ? "google" : "internal"; + await this.updateGoogleCalendarUrl(); + } + + async updateGoogleCalendarUrl() { + if (this.state.calendarProvider !== "google") { + return; + } + + const writerId = Number(this.state.writerId || 0); + if (!writerId) { + this.state.googleCalendarUrl = "https://calendar.google.com/calendar/embed?ctz=Europe%2FBerlin"; + return; + } + + let email = ""; + let calendarId = ""; + try { + const users = await this.orm.read("res.users", [writerId], ["email", "login", "partner_id"]); + const user = (users && users[0]) || {}; + email = (user.email || user.login || "").trim(); + const partnerId = user.partner_id && user.partner_id[0]; + if (partnerId) { + const partners = await this.orm.read("res.partner", [partnerId], ["google_calendar_id"]); + const partner = (partners && partners[0]) || {}; + calendarId = String(partner.google_calendar_id || "").trim(); + } + } catch (error) { + email = ""; + calendarId = ""; + } + + const sourceCalendar = calendarId || email; + if (!sourceCalendar) { + this.state.googleCalendarUrl = "https://calendar.google.com/calendar/embed?ctz=Europe%2FBerlin"; + return; + } + + this.state.googleCalendarUrl = `https://calendar.google.com/calendar/embed?src=${encodeURIComponent(sourceCalendar)}&ctz=Europe%2FBerlin`; + } + + async loadProjectQueries(projectId) { + const queryRows = await this.callQueryModel("get_project_queries", [projectId], []); + this.state.projectQueries = queryRows || []; + } + + async onSelectSavedQuery(ev) { + const rawValue = String(ev.target.value || "").trim(); + if (!rawValue) { + this.state.selectedQueryId = null; + this.state.selectedQueryType = "query"; + this.state.companyOptions = []; + this.state.searchBlocked = false; + this.state.selectedCompanyLineId = null; + this.state.selectedCompanyOptionId = null; + return; + } + + if (rawValue.startsWith("reschedule:")) { + const projectId = Number(rawValue.split(":")[1] || this.state.projectId || 0); + await this.loadRescheduleDetails(projectId); + return; + } + + const queryId = Number(rawValue) || null; + if (!queryId) { + return; + } + await this.loadSavedQueryDetails(queryId); + } + + async loadSavedQueryDetails(queryId) { + const details = await this.callQueryModel("get_query_details", [queryId], { ok: false }); + if (!details || !details.ok) { + this.notification.add("Suchanfrage konnte nicht geladen werden.", { type: "warning" }); + return; + } + + const query = details.query || {}; + this.state.selectedQueryId = Number(query.id || queryId); + this.state.selectedQueryType = "query"; + this.state.locationQuery = query.location_query || ""; + this.state.radiusKm = Number(query.radius_km || 20); + this.state.industryQuery = query.industry_query || ""; + this.state.companyOptions = details.companies || []; + this.state.searchBlocked = !Boolean(query.can_repeat); + this.state.hasSearchedCompanies = true; + this.state.showNewQueryForm = false; + this.state.selectedCompanyLineId = null; + this.state.selectedCompanyOptionId = null; + this.updateRadiusCircle(); + } + + async loadRescheduleDetails(projectId) { + const details = await this.callQueryModel("get_reschedule_details", [projectId], { ok: false }); + if (!details || !details.ok) { + this.notification.add("Wiedervorlagen konnten nicht geladen werden.", { type: "warning" }); + return; + } + + this.state.selectedQueryId = details.query?.id || `reschedule:${projectId}`; + this.state.selectedQueryType = "reschedule"; + this.state.companyOptions = details.companies || []; + this.state.searchBlocked = false; + this.state.hasSearchedCompanies = true; + this.state.showNewQueryForm = false; + this.state.selectedCompanyLineId = null; + this.state.selectedCompanyOptionId = null; + } + + showCreateNewQuery() { + this.state.showNewQueryForm = true; + this.state.selectedQueryId = null; + this.state.selectedQueryType = "query"; + this.state.selectedCompanyLineId = null; + this.state.selectedCompanyOptionId = null; + this.state.companyOptions = []; + this.state.searchBlocked = false; + } + + async callQueryModel(method, args = [], fallback = null) { + try { + const result = await this.orm.call("dss.phonecenter.query", method, args); + this.state.queryModelAvailable = true; + return result; + } catch (error) { + const msg = String((error && (error.message || error.data?.message || error.data?.debug)) || ""); + if (msg.includes("has no attribute")) { + // Method not yet deployed on all workers: keep model available and let caller decide fallback. + return fallback; + } + if ( + msg.includes("dss.phonecenter.query") + || msg.includes("KeyError") + || msg.includes("RPC_ERROR") + ) { + this.state.queryModelAvailable = false; + this.state.projectQueries = []; + this.state.selectedQueryId = null; + this.state.selectedQueryType = "query"; + this.state.showNewQueryForm = true; + if (!this.state.queryModelWarningShown) { + this.notification.add("Query-Speicher noch nicht verfuegbar. Suche laeuft temporaer ohne gespeicherte Anfragen.", { type: "warning" }); + this.state.queryModelWarningShown = true; + } + return fallback; + } + throw error; + } + } + + onContactChange(ev) { + const contactId = Number(ev.target.value) || null; + this.state.contactId = contactId; + const match = this.state.contacts.find((item) => item.id === contactId); + if (!match) { + return; + } + this.applyContactInfo(match.source); + } + + applyContactInfo(source) { + const website = source.contact_web || source.client_short_website || ""; + this.state.contactInfo = { + company: source.contact_company_name || source.client?.[1] || "", + name: source.contact_name || "", + phone: source.contact_telefon || "", + email: source.contact_email || "", + website, + city: source.contact_city || "", + address: "", + rating: "", + source: "vertrag", + verified: true, + verifiedAt: "", + analysis: "", + }; + this.state.contactWebsiteHref = buildWebsiteUrl(website); + this.state.contactPhones = buildPhoneLinks(this.state.contactInfo.phone); + this.state.selectedCompanyLineId = null; + this.state.selectedCompanyOptionId = null; + this.state.badContactSummary = ""; + this.state.badContactActions = []; + + if (website) { + this.openWebsiteInBrowser(website); + } else { + this.state.browserQuery = `${this.state.contactInfo.company || ""} ${this.state.contactInfo.city || ""}`.trim(); + this.state.browserMode = "maps"; + this.state.browserUrl = buildSafeUrl(this.state.browserMode, this.state.browserQuery); + } + this.state.mapOverlayUrl = ""; + this.state.showMapOverlay = false; + } + + async findLocation() { + const query = (this.state.locationQuery || "").trim(); + if (!query) { + this.notification.add("Bitte zuerst einen Ort eingeben.", { type: "warning" }); + return; + } + + try { + const result = await this.orm.call("dss.settings", "phonecenter_check_location", [query]); + if (!result || !result.found) { + this.state.locationFound = false; + this.state.locationLat = null; + this.state.locationLng = null; + this.notification.add("Ort wurde nicht gefunden.", { type: "warning" }); + return; + } + this.state.locationFound = true; + this.state.locationLat = result.lat; + this.state.locationLng = result.lng; + this.state.browserMode = "maps"; + this.state.browserQuery = result.address || query; + this.state.mapOverlayUrl = ""; + this.state.showMapOverlay = false; + this.autoAdjustMapZoom(); + this.updateRadiusCircle(); + this.loadBrowser(); + } catch (error) { + this.state.locationFound = false; + this.state.locationLat = null; + this.state.locationLng = null; + this.notification.add("Ort konnte nicht geprueft werden.", { type: "danger" }); + } + } + + onRadiusInput() { + this.updateRadiusCircle(); + this.autoAdjustMapZoom(); + if (this.state.locationFound) { + this.state.browserMode = "maps"; + this.state.browserQuery = this.state.locationQuery || this.state.browserQuery; + this.loadBrowser(); + } + } + + autoAdjustMapZoom() { + const radius = Number(this.state.radiusKm || 20); + let zoom = 12; + if (radius >= 800) { + zoom = 6; + } else if (radius >= 400) { + zoom = 7; + } else if (radius >= 200) { + zoom = 8; + } else if (radius >= 100) { + zoom = 9; + } else if (radius >= 50) { + zoom = 10; + } else if (radius >= 25) { + zoom = 11; + } + if (this.state.locationLat && this.state.locationLng) { + this.state.browserUrl = `https://www.google.com/maps?q=${this.state.locationLat},${this.state.locationLng}&z=${zoom}&output=embed`; + } + } + + updateRadiusCircle() { + const base = Number(this.state.radiusKm || 0); + const pixel = Math.max(40, Math.min(760, base * 0.45)); + this.state.radiusCirclePx = pixel; + } + + searchCompanies() { + return this._searchCompanies(); + } + + async _searchCompanies() { + if (!this.state.projectId) { + this.notification.add("Bitte zuerst ein Projekt waehlen.", { type: "warning" }); + return; + } + + if (!this.state.selectedQueryId && !this.state.showNewQueryForm) { + this.notification.add("Bitte alte Suchanfrage waehlen oder Neue Anfrage starten.", { type: "warning" }); + return; + } + + if (this.state.selectedQueryType === "reschedule" && !this.state.showNewQueryForm) { + this.notification.add("Wiedervorlagen sind nur eine Ansicht. Fuer neue Suche bitte Anfrage waehlen oder Neue Anfrage starten.", { type: "warning" }); + return; + } + + if (this.state.searchBlocked) { + this.notification.add( + `Naechste Suche erst ab ${this.state.minEntriesForResearch} Eintraegen in der Liste moeglich.`, + { type: "warning" } + ); + return; + } + + const branch = (this.state.industryQuery || "").trim(); + if (!branch) { + this.notification.add("Bitte eine Branche eingeben.", { type: "warning" }); + return; + } + + try { + if (!this.state.queryModelAvailable) { + const payload = { + industry: branch, + location_query: (this.state.locationQuery || this.state.contactInfo.city || "").trim(), + lat: this.state.locationLat, + lng: this.state.locationLng, + radius_km: Number(this.state.radiusKm || 20), + current_options: this.state.companyOptions, + }; + const result = await this.orm.call("dss.settings", "phonecenter_search_companies", [payload]); + this.state.companyOptions = result.companies || []; + this.state.minEntriesForResearch = Number(result.min_entries || this.state.minEntriesForResearch || 8); + this.state.searchBlocked = Boolean(result.search_blocked); + this.state.hasSearchedCompanies = true; + if (!this.state.companyOptions.length) { + this.notification.add("Keine Unternehmen gefunden.", { type: "warning" }); + } else { + this.notification.add(`${this.state.companyOptions.length} Unternehmen in der Liste.`, { type: "success" }); + } + return; + } + + let queryId = this.state.selectedQueryId; + if (!queryId) { + const created = await this.callQueryModel("create_query", [{ + project_id: this.state.projectId, + location_query: (this.state.locationQuery || "").trim(), + radius_km: Number(this.state.radiusKm || 20), + industry_query: branch, + }], { ok: false }); + if (!created || !created.ok || !created.query) { + this.notification.add((created && created.message) || "Anfrage konnte nicht erstellt werden.", { type: "warning" }); + return; + } + queryId = created.query.id; + this.state.selectedQueryId = Number(queryId); + this.state.showNewQueryForm = false; + await this.loadProjectQueries(this.state.projectId); + } + + const repeatInfo = await this.callQueryModel("can_repeat_query", [queryId], { ok: false, can_repeat: false }); + if (!repeatInfo.ok || !repeatInfo.can_repeat) { + this.state.searchBlocked = true; + this.notification.add("Vorhandene Such-Eintraege muessen erst abgearbeitet werden.", { type: "warning" }); + return; + } + + const payload = { + industry: branch, + location_query: (this.state.locationQuery || this.state.contactInfo.city || "").trim(), + lat: this.state.locationLat, + lng: this.state.locationLng, + radius_km: Number(this.state.radiusKm || 20), + current_options: this.state.companyOptions, + }; + const result = await this.orm.call("dss.settings", "phonecenter_search_companies", [payload]); + const saved = await this.callQueryModel("save_query_results", [queryId, result.companies || []], { ok: false }); + if (!saved || !saved.ok) { + this.notification.add("Suchergebnisse konnten nicht gespeichert werden.", { type: "warning" }); + return; + } + + this.state.companyOptions = saved.companies || []; + this.state.minEntriesForResearch = Number(result.min_entries || this.state.minEntriesForResearch || 8); + this.state.searchBlocked = !Boolean((saved.query || {}).can_repeat); + this.state.hasSearchedCompanies = true; + await this.loadProjectQueries(this.state.projectId); + if (!this.state.companyOptions.length) { + this.notification.add("Keine Unternehmen gefunden.", { type: "warning" }); + } else { + this.notification.add(`${this.state.companyOptions.length} Unternehmen in der Liste.`, { type: "success" }); + } + } catch (error) { + this.notification.add("Unternehmenssuche fehlgeschlagen.", { type: "danger" }); + } + } + + onCompanySelect(ev) { + return this._onCompanySelect(ev); + } + + async _onCompanySelect(ev) { + const selectedId = ev.target.value; + if (!selectedId) { + this.state.selectedCompanyOptionId = null; + this.state.selectedCompanyLineId = null; + return; + } + this.state.selectedCompanyOptionId = selectedId; + const company = this.state.companyOptions.find((option) => option.id === selectedId); + if (!company) { + return; + } + this.state.selectedCompanyLineId = company.line_id || null; + + try { + const detailsResult = await this.orm.call("dss.settings", "phonecenter_get_company_details", [{ company }]); + if (!detailsResult || !detailsResult.ok || !detailsResult.company) { + this.notification.add("Unternehmensdetails konnten nicht verifiziert werden.", { type: "warning" }); + return; + } + + const details = detailsResult.company; + const mapQuery = details.address || `${details.name || company.name || ""} ${details.city || this.state.locationQuery || ""}`.trim(); + const mapUrl = this._buildMapEmbedUrl(details, mapQuery); + + this.state.contactInfo = { + company: details.name || company.name || "", + name: "", + phone: details.phone || "", + email: details.email || "", + website: details.website || "", + city: details.city || this.state.locationQuery || "", + address: details.address || "", + rating: details.rating || "", + source: details.source || "api", + verified: Boolean(details.verified), + verifiedAt: details.verified_at || "", + analysis: details.analysis || "", + }; + this.state.contactWebsiteHref = buildWebsiteUrl(details.website || ""); + this.state.contactPhones = buildPhoneLinks(this.state.contactInfo.phone); + this.state.badContactSummary = ""; + this.state.badContactActions = []; + + if (details.website) { + this.openWebsiteInBrowser(details.website); + this.state.mapOverlayUrl = mapUrl; + this.state.showMapOverlay = Boolean(mapUrl); + } else { + this.state.browserMode = "maps"; + this.state.browserQuery = mapQuery; + this.state.browserUrl = mapUrl; + this.state.mapOverlayUrl = ""; + this.state.showMapOverlay = false; + } + + this.notification.add("Unternehmensdetails verifiziert geladen.", { type: "success" }); + } catch (error) { + this.notification.add("Unternehmensdetails konnten nicht geladen werden.", { type: "danger" }); + } + } + + async onWriterChange(ev) { + this.state.writerId = Number(ev.target.value) || null; + this.loadEventsFromContracts(); + await this.updateGoogleCalendarUrl(); + } + + _buildMapEmbedUrl(details, fallbackQuery) { + const lat = details && details.lat; + const lng = details && details.lng; + if (lat !== undefined && lat !== null && lng !== undefined && lng !== null) { + return `https://www.google.com/maps?q=${encodeURIComponent(`${lat},${lng}`)}&z=15&output=embed`; + } + const mapsUrl = (details && details.google_maps_url) || ""; + if (mapsUrl.includes("google.")) { + const query = (details && details.address) || fallbackQuery || mapsUrl; + return buildSafeUrl("maps", query); + } + return buildSafeUrl("maps", (details && details.address) || fallbackQuery || ""); + } + + closeMapOverlay() { + this.state.showMapOverlay = false; + } + + openWebsiteInBrowser(rawWebsite) { + const original = (rawWebsite || "").trim(); + const safeUrl = preferHttps(buildWebsiteUrl(original)); + this.state.browserMode = "website"; + this.state.browserQuery = safeUrl || original; + if (!safeUrl) { + this.state.browserUrl = "about:blank"; + return; + } + this.state.browserUrl = safeUrl; + } + + openMapOverlay() { + if (this.state.mapOverlayUrl) { + this.state.showMapOverlay = true; + } + } + + loadEventsFromContracts() { + const writerId = this.state.writerId; + if (!writerId) { + this.state.events = []; + this.rebuildCalendar(); + return; + } + + const rawEvents = this.state.contacts + .map((contact) => contact.source) + .filter((contract) => contract.contract_writer && contract.contract_writer[0] === writerId && contract.start_date) + .map((contract) => ({ + id: contract.id, + title: contract.contract_name || `Vertrag ${contract.id}`, + startDate: contract.start_date, + endDate: contract.calc_end_date || contract.start_date, + note: contract.contract_remark || "", + })); + + this.state.events = rawEvents; + this.rebuildCalendar(); + } + + rebuildCalendar() { + const year = this.state.calendarYear; + const month = this.state.calendarMonth; + const first = new Date(year, month, 1); + const startWeekday = (first.getDay() + 6) % 7; + const lastDay = new Date(year, month + 1, 0).getDate(); + const eventCountPerDay = new Map(); + + for (const event of this.state.events) { + const start = event.startDate ? String(event.startDate).slice(0, 10) : ""; + if (!start) { + continue; + } + const day = Number(start.slice(8, 10)); + const m = Number(start.slice(5, 7)) - 1; + const y = Number(start.slice(0, 4)); + if (y === year && m === month) { + eventCountPerDay.set(day, (eventCountPerDay.get(day) || 0) + 1); + } + } + + const names = ["Januar", "Februar", "Maerz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]; + this.state.calendarTitle = `${names[month]} ${year}`; + + const days = []; + for (let i = 0; i < startWeekday; i++) { + days.push({ key: `e-${i}`, day: 0, count: 0, cssClass: "inactive" }); + } + for (let day = 1; day <= lastDay; day++) { + const dateText = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + const selected = this.state.selectedDate === dateText; + days.push({ + key: dateText, + day, + count: eventCountPerDay.get(day) || 0, + dateText, + cssClass: selected ? "selected" : "", + }); + } + while (days.length % 7 !== 0) { + days.push({ key: `x-${days.length}`, day: 0, count: 0, cssClass: "inactive" }); + } + + this.state.calendarDays = days; + } + + onCalendarDayClick(ev) { + const dateText = ev.currentTarget.dataset.dateText || ""; + if (!dateText) { + return; + } + this.state.selectedDate = dateText; + const dayEvent = this.state.events.find((event) => event.startDate === dateText); + if (dayEvent) { + this.state.selectedEventId = dayEvent.id; + this.state.eventForm = { + title: dayEvent.title, + startDate: dayEvent.startDate, + endDate: dayEvent.endDate, + note: dayEvent.note || "", + }; + this.state.editMode = false; + } + this.rebuildCalendar(); + } + + setClassification(value) { + this.state.classification = value; + } + + setClassificationKeinInteresse() { + this.setClassification("kein_interesse"); + this.state.showNoInterestModal = true; + this.state.noInterestPartner = "nicht_bekannt"; + } + + setClassificationKeinGuterKontakt() { + this.setClassification("kein_guter_kontakt"); + this.state.showBadContactModal = true; + this.state.badContactReason = ""; + } + + setClassificationRueckruf() { + this.setClassification("rueckruf"); + this.openRescheduleModal(); + } + + setClassificationEmailVersenden() { + this.setClassification("email_versenden"); + } + + setClassificationTermin() { + this.setClassification("termin"); + } + + closeBadContactModal() { + this.state.showBadContactModal = false; + } + + onBadContactReasonInput(ev) { + this.state.badContactReason = ev.target.value || ""; + } + + async submitBadContactReason() { + const reason = (this.state.badContactReason || "").trim(); + if (!reason) { + this.notification.add("Bitte Grund fuer 'kein guter Kontakt' eingeben.", { type: "warning" }); + return; + } + + this.state.isAnalyzingBadContact = true; + try { + const review = await this.orm.call("dss.settings", "phonecenter_review_bad_contact", [{ + reason, + note: this.state.classificationNote || "", + contact: this.state.contactInfo, + }]); + if (!review || !review.ok) { + this.notification.add((review && review.message) || "Analyse konnte nicht erstellt werden.", { type: "warning" }); + return; + } + + this.state.badContactSummary = review.summary || ""; + this.state.badContactActions = review.actions || []; + this.state.classificationNote = this.state.classificationNote + ? `${this.state.classificationNote}\n[Kein guter Kontakt] ${reason}` + : `[Kein guter Kontakt] ${reason}`; + this.state.showBadContactModal = false; + } catch (error) { + const msg = String((error && (error.message || error.data?.message || error.data?.debug)) || ""); + const fallback = buildBadContactFallback(reason, this.state.contactInfo); + this.state.badContactSummary = fallback.summary; + this.state.badContactActions = fallback.actions; + this.state.classificationNote = this.state.classificationNote + ? `${this.state.classificationNote}\n[Kein guter Kontakt] ${reason}` + : `[Kein guter Kontakt] ${reason}`; + this.state.showBadContactModal = false; + + if (msg.includes("phonecenter_review_bad_contact") || msg.includes("AttributeError")) { + this.notification.add("LLM-Analyse auf diesem Server noch nicht verfuegbar, Fallback-Empfehlungen geladen.", { type: "warning" }); + } else { + this.notification.add("LLM-Analyse fehlgeschlagen, Fallback-Empfehlungen geladen.", { type: "warning" }); + } + } finally { + this.state.isAnalyzingBadContact = false; + } + } + + async applyRecommendedAction(ev) { + const action = (ev.currentTarget.dataset.action || "").trim(); + if (!action) { + return; + } + + if (action === "reschedule") { + this.openRescheduleModal(); + return; + } + + const decisionLabels = { + reschedule: "Wiedervorlage", + discard: "Komplett verwerfen", + research: "Weitere Recherchen", + keep: "Zunaechst behalten", + }; + + if (action === "discard" && (!this.state.queryModelAvailable || !this.state.selectedCompanyLineId)) { + this.notification.add("Verwerfen ist nur bei gespeicherten Query-Kontakten moeglich.", { type: "warning" }); + return; + } + + const currentLineId = this.state.selectedCompanyLineId; + try { + if (this.state.queryModelAvailable && currentLineId) { + const result = await this.callQueryModel("apply_contact_decision", [ + currentLineId, + action, + this.state.badContactReason || "", + this.state.badContactSummary || "", + ], { ok: false }); + + if (!result || !result.ok) { + if (action === "discard") { + this._removeCompanyFromDropdown(currentLineId); + await this._loadNextCompanyAfterRemoval(); + } + this.notification.add("Backend-Entscheidungsmethode nicht verfuegbar. Aktion lokal angewendet.", { type: "warning" }); + return; + } + + if (action === "discard") { + this._removeCompanyFromDropdown(currentLineId); + if (this.state.selectedQueryId) { + await this._reloadCurrentBucket(); + await this.loadProjectQueries(this.state.projectId); + } + await this._loadNextCompanyAfterRemoval(); + } else if (this.state.selectedQueryId) { + await this._reloadCurrentBucket(); + await this.loadProjectQueries(this.state.projectId); + } + } + } catch (error) { + if (action === "discard") { + this._removeCompanyFromDropdown(currentLineId); + await this._loadNextCompanyAfterRemoval(); + this.notification.add("Verwerfen lokal angewendet (Backend noch nicht aktualisiert).", { type: "warning" }); + return; + } + this.notification.add("Entscheidung konnte serverseitig nicht gespeichert werden.", { type: "danger" }); + return; + } + + this.notification.add(`Empfehlung gesetzt: ${decisionLabels[action] || action}.`, { type: "success" }); + } + + closeNoInterestModal() { + this.state.showNoInterestModal = false; + } + + onNoInterestPartnerChange(ev) { + this.state.noInterestPartner = ev.target.value || "nicht_bekannt"; + } + + async confirmNoInterestPartner() { + const partnerMap = { + entscheider: "Entscheider", + geschaeftsfuehrer: "Geschaeftsfuehrer", + mitarbeiter: "Mitarbeiter", + werbeabteilung: "Werbeabteilung", + nicht_bekannt: "nicht bekannt", + }; + const partner = this.state.noInterestPartner || "nicht_bekannt"; + const label = partnerMap[partner] || "nicht bekannt"; + + this.state.classificationNote = this.state.classificationNote + ? `${this.state.classificationNote}\n[Kein Interesse] Anrufpartner: ${label}` + : `[Kein Interesse] Anrufpartner: ${label}`; + + const currentLineId = this.state.selectedCompanyLineId; + if (this.state.queryModelAvailable && currentLineId) { + await this.callQueryModel("apply_contact_decision", [ + currentLineId, + "discard", + `Anrufpartner: ${label}`, + this.state.classificationNote || "", + ], { ok: false }); + } + + this._removeCompanyFromDropdown(currentLineId); + await this._loadNextCompanyAfterRemoval(); + this.state.showNoInterestModal = false; + this.notification.add("Kontakt als abgeschlossen markiert.", { type: "success" }); + } + + openRescheduleModal() { + if (!this.state.selectedCompanyLineId) { + this.notification.add("Bitte zuerst ein Unternehmen aus der Liste waehlen.", { type: "warning" }); + return; + } + const baseDays = Math.max(1, Number(this.state.rescheduleDays || 7)); + const target = new Date(Date.now() + baseDays * 24 * 60 * 60 * 1000); + this.state.rescheduleDays = baseDays; + this.state.rescheduleAt = formatDateTimeLocal(target); + this.state.rescheduleReason = ""; + this.state.showRescheduleModal = true; + } + + closeRescheduleModal() { + this.state.showRescheduleModal = false; + } + + onRescheduleDaysInput(ev) { + const days = Math.max(1, Number(ev.target.value || 1)); + this.state.rescheduleDays = days; + const target = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + this.state.rescheduleAt = formatDateTimeLocal(target); + } + + onRescheduleAtInput(ev) { + this.state.rescheduleAt = ev.target.value || ""; + } + + onRescheduleReasonInput(ev) { + this.state.rescheduleReason = ev.target.value || ""; + } + + async confirmReschedule() { + const currentLineId = this.state.selectedCompanyLineId; + if (!currentLineId) { + this.notification.add("Kein Kontakt fuer Wiedervorlage ausgewaehlt.", { type: "warning" }); + return; + } + + if (!this.state.queryModelAvailable) { + this.notification.add("Wiedervorlage benoetigt den Query-Speicher. Bitte spaeter erneut versuchen.", { type: "warning" }); + return; + } + + const rescheduleAt = (this.state.rescheduleAt || "").trim(); + if (!rescheduleAt) { + this.notification.add("Bitte Zeitpunkt fuer die Wiedervorlage angeben.", { type: "warning" }); + return; + } + + const summary = [ + this.state.rescheduleReason || "", + `Wiedervorlage bis: ${rescheduleAt}`, + `Zeitraum: ${Number(this.state.rescheduleDays || 0)} Tag(e)`, + ].filter(Boolean).join("\n"); + const result = await this.callQueryModel("apply_contact_decision", [ + currentLineId, + "reschedule", + `Wiedervorlage bis ${rescheduleAt}`, + summary, + ], { ok: false }); + const saved = Boolean(result && result.ok); + + if (!saved) { + this.notification.add("Wiedervorlage konnte nicht gespeichert werden.", { type: "warning" }); + return; + } + + this._removeCompanyFromDropdown(currentLineId); + await this._loadNextCompanyAfterRemoval(); + await this.loadProjectQueries(this.state.projectId); + await this._reloadCurrentBucket(); + this.state.showRescheduleModal = false; + this.notification.add("Kontakt wurde bis zur Wiedervorlage aus der Liste entfernt.", { type: "success" }); + } + + async _reloadCurrentBucket() { + if (!this.state.selectedQueryId) { + return; + } + if (this.state.selectedQueryType === "reschedule") { + await this.loadRescheduleDetails(this.state.projectId); + return; + } + + const queryId = Number(this.state.selectedQueryId) || null; + if (queryId) { + await this.loadSavedQueryDetails(queryId); + } + } + + _removeCompanyFromDropdown(lineId) { + if (!lineId) { + return; + } + this.state.companyOptions = this.state.companyOptions.filter((item) => item.line_id !== lineId); + if (this.state.selectedCompanyLineId === lineId) { + this.state.selectedCompanyLineId = null; + this.state.selectedCompanyOptionId = null; + } + } + + async _loadNextCompanyAfterRemoval() { + const next = this.state.companyOptions[0]; + if (!next) { + this.state.selectedCompanyOptionId = null; + this.state.selectedCompanyLineId = null; + return; + } + await this._onCompanySelect({ target: { value: next.id } }); + } + + loadBrowser() { + this.state.browserUrl = buildSafeUrl(this.state.browserMode, this.state.browserQuery); + } + + openInNewTab() { + const url = buildSafeUrl(this.state.browserMode, this.state.browserQuery); + window.open(url, "_blank", "noopener,noreferrer"); + } + + prevMonth() { + if (this.state.calendarMonth === 0) { + this.state.calendarMonth = 11; + this.state.calendarYear -= 1; + } else { + this.state.calendarMonth -= 1; + } + this.rebuildCalendar(); + } + + nextMonth() { + if (this.state.calendarMonth === 11) { + this.state.calendarMonth = 0; + this.state.calendarYear += 1; + } else { + this.state.calendarMonth += 1; + } + this.rebuildCalendar(); + } + + enableEdit() { + if (!this.state.selectedEventId) { + this.notification.add("Bitte zuerst einen Termin aus dem Kalender auswaehlen.", { type: "warning" }); + return; + } + this.state.editMode = true; + } + + async saveEvent() { + if (!this.state.selectedEventId) { + return; + } + const eventId = this.state.selectedEventId; + await this.orm.write("dss.contracts", [eventId], { + contract_name: this.state.eventForm.title, + start_date: this.state.eventForm.startDate || false, + calc_end_date: this.state.eventForm.endDate || false, + contract_remark: this.state.eventForm.note || "", + }); + + const localEvent = this.state.events.find((event) => event.id === eventId); + if (localEvent) { + localEvent.title = this.state.eventForm.title; + localEvent.startDate = this.state.eventForm.startDate; + localEvent.endDate = this.state.eventForm.endDate; + localEvent.note = this.state.eventForm.note; + } + + this.state.editMode = false; + this.state.selectedDate = this.state.eventForm.startDate || this.state.selectedDate; + this.rebuildCalendar(); + this.notification.add("Termin wurde gespeichert.", { type: "success" }); + } + + resetEventForm() { + this.state.eventForm = { + title: "", + startDate: formatDate(new Date()), + endDate: formatDate(new Date()), + note: "", + }; + this.state.selectedDate = null; + this.state.selectedEventId = null; + this.state.editMode = false; + } +} + +PhoneCenterAction.template = "DigitalSignage.PhoneCenterAction"; + +registry.category("actions").add("dss_phone_center_action", PhoneCenterAction); diff --git a/static/src/scss/navbar_ai_button.scss b/static/src/scss/navbar_ai_button.scss new file mode 100755 index 0000000..d6525a5 --- /dev/null +++ b/static/src/scss/navbar_ai_button.scss @@ -0,0 +1,50 @@ +.o_ai_agent_fullscreen_dialog { + width: 96vw; + max-width: 96vw; + margin: 1vh auto; +} + +.o_ai_agent_fullscreen_dialog .modal-content { + min-height: 96vh; +} + +.o_ai_agent_dialog { + display: flex; + flex-direction: column; +} + +.o_contract_go_widget { + .o_contract_go_input { + width: 110px; + border-radius: 0; + padding-left: 10px; + padding-right: 10px; + color: #fff; + background-color: transparent; + border-color: rgba(255, 255, 255, 0.5); + + &::placeholder { + color: rgba(255, 255, 255, 0.6); + } + + &:focus { + background-color: transparent; + color: #fff; + border-color: #fff; + box-shadow: none; + } + } + + .o_contract_go_button { + width: 32px; + height: 32px; + border-radius: 50%; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + flex-shrink: 0; + } +} diff --git a/static/src/scss/phone_center_action.scss b/static/src/scss/phone_center_action.scss new file mode 100755 index 0000000..4a77b25 --- /dev/null +++ b/static/src/scss/phone_center_action.scss @@ -0,0 +1,501 @@ +.o_dss_phone_center { + height: 100%; + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + background: linear-gradient(120deg, #f4f7fb 0%, #eef4ef 100%); +} + +.o_dss_phone_center__topbar { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.o_dss_phone_center__topbar--callcenter { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: end; +} + +.o_dss_phone_center__topbar--callcenter_bottom { + grid-template-columns: 1fr; +} + +.o_dss_phone_center__field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.o_dss_phone_center__field label { + font-weight: 600; + font-size: 12px; + color: #264653; +} + +.o_dss_phone_center select, +.o_dss_phone_center input, +.o_dss_phone_center textarea, +.o_dss_phone_center button { + border: 1px solid #b9c6cd; + border-radius: 8px; + padding: 8px; + font-size: 13px; + background: #ffffff; +} + +.o_dss_phone_center button { + cursor: pointer; + background: #1d3557; + color: #ffffff; + border-color: #1d3557; +} + +.o_dss_phone_center button.secondary { + background: #ffffff; + color: #1d3557; +} + +.o_dss_phone_center__layout { + flex: 1; + min-height: 0; + display: grid; + gap: 12px; + grid-template-columns: minmax(280px, 1fr) minmax(680px, 2.4fr); +} + +.o_dss_phone_center__col { + min-height: 0; + display: grid; + gap: 12px; +} + +.o_dss_phone_center__col--left, +.o_dss_phone_center__col--right { + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); +} + +.o_dss_phone_center__col--center { + grid-template-rows: minmax(0, 1fr); +} + +.o_dss_phone_center__panel { + min-height: 0; + border: 1px solid #d8e2dc; + border-radius: 10px; + background: rgba(255, 255, 255, 0.95); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; + padding: 10px; + gap: 10px; +} + +.o_dss_phone_center__panel_title { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; + color: #264653; +} + +.o_dss_phone_center__info_grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + font-size: 13px; +} + +.o_dss_phone_center__info_grid div { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.o_dss_phone_center__phone_links { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.o_dss_phone_center__phone_links a { + color: #0a5ea8; + text-decoration: underline; + font-weight: 600; +} + +.o_dss_phone_center__hint { + margin-top: auto; + font-size: 12px; + color: #61717a; +} + +.o_dss_phone_center__analysis { + border: 1px solid #d8e2dc; + border-radius: 8px; + background: #f8fbff; + padding: 8px; + font-size: 12px; + color: #243b53; +} + +.o_dss_phone_center__analysis p { + margin: 4px 0 0; +} + +.o_dss_phone_center__search_block { + margin-top: 8px; + border-top: 1px dashed #d2dbe0; + padding-top: 10px; + display: grid; + gap: 8px; +} + +.o_dss_phone_center__inline_fields { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.o_dss_phone_center__muted { + font-size: 11px; + color: #657580; +} + +.o_dss_phone_center__classification { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.o_dss_phone_center__classification button.active { + background: #2a9d8f; + border-color: #2a9d8f; +} + +.o_dss_phone_center__panel--bottom textarea { + min-height: 120px; + resize: vertical; +} + +.o_dss_phone_center__recommendations { + border: 1px solid #d8e2dc; + border-radius: 8px; + padding: 8px; + background: #f8fbff; + font-size: 12px; + color: #243b53; +} + +.o_dss_phone_center__recommendations p { + margin: 6px 0; +} + +.o_dss_phone_center__recommendation_actions { + display: grid; + gap: 6px; +} + +.o_dss_phone_center__browser_toolbar { + display: grid; + gap: 8px; + grid-template-columns: 150px 1fr auto auto; +} + +.o_dss_phone_center__browser_wrap { + min-height: 0; +} + +.o_dss_phone_center__panel_header_main { + margin: -10px -10px 0; + padding: 10px; + border-bottom: 1px solid #d8e2dc; + background: linear-gradient(135deg, #eef5f8 0%, #f5faf5 100%); + border-radius: 10px 10px 0 0; +} + +.o_dss_phone_center__panel_header_sub { + margin: 0 -10px; + padding: 10px; + background: #edf3fb; + border-top: 1px solid #d7e2ee; + border-bottom: 1px solid #d7e2ee; +} + +.o_dss_phone_center__panel_switch { + display: inline-flex; + gap: 8px; +} + +.o_dss_phone_center__panel_switch--icons button { + min-width: 38px; + width: 38px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.o_dss_phone_center__panel_switch--icons button i { + font-size: 14px; +} + +.o_dss_phone_center__panel_switch button.active { + background: #2a9d8f; + color: #ffffff; + border-color: #2a9d8f; +} + +.o_dss_phone_center__iframe { + border: 1px solid #d2dbe0; + border-radius: 8px; + width: 100%; + min-height: 0; + flex: 1; + background: #ffffff; +} + +.o_dss_phone_center__map_wrap { + position: relative; + min-height: 0; + flex: 1; + display: flex; +} + +.o_dss_phone_center__map_overlay_toggle { + position: absolute; + top: 10px; + right: 10px; + z-index: 6; +} + +.o_dss_phone_center__map_overlay { + position: absolute; + top: 10px; + right: 10px; + width: 46%; + max-width: 420px; + height: 52%; + max-height: 320px; + border: 1px solid #d2dbe0; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22); + z-index: 7; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.o_dss_phone_center__map_overlay_head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + background: #f4f8fb; + border-bottom: 1px solid #d2dbe0; + font-size: 12px; + font-weight: 600; + color: #1d3557; +} + +.o_dss_phone_center__map_overlay_head button { + width: 28px; + height: 28px; + padding: 0; +} + +.o_dss_phone_center__map_overlay_iframe { + width: 100%; + height: 100%; + border: 0; +} + +.o_dss_phone_center__modal_backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + z-index: 90; + display: flex; + align-items: center; + justify-content: center; +} + +.o_dss_phone_center__modal { + width: calc(100% - 24px); + max-width: 520px; + background: #ffffff; + border-radius: 10px; + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.24); + padding: 12px; + display: grid; + gap: 10px; +} + +.o_dss_phone_center__modal textarea { + min-height: 120px; + resize: vertical; +} + +.o_dss_phone_center__modal_actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.o_dss_phone_center__map_circle { + position: absolute; + border: 2px solid rgba(220, 38, 38, 0.65); + background: rgba(220, 38, 38, 0.15); + border-radius: 999px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.o_dss_phone_center__calendar_toolbar { + display: grid; + gap: 8px; + grid-template-columns: 1fr; +} + +.o_dss_phone_center__calendar_toolbar--top { + grid-template-columns: 1fr 1fr; +} + +.o_dss_phone_center__calendar_settings { + display: flex; + flex-direction: column; + gap: 6px; +} + +.o_dss_phone_center__calendar_settings label { + font-size: 12px; + font-weight: 600; + color: #264653; +} + +.o_dss_phone_center__calendar_split { + min-height: 0; + flex: 1; + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); +} + +.o_dss_phone_center__calendar_area, +.o_dss_phone_center__event_area { + min-height: 0; + border: 1px solid #d8e2dc; + border-radius: 10px; + background: #ffffff; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.o_dss_phone_center__month_switch { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.o_dss_phone_center__month_switch button { + width: 34px; + height: 34px; + padding: 0; +} + +.o_dss_phone_center__calendar_grid { + display: grid; + gap: 6px; + grid-template-columns: repeat(7, minmax(0, 1fr)); + overflow: auto; +} + +.o_dss_phone_center__calendar_grid button { + min-height: 52px; + text-align: left; + font-size: 11px; + background: #f6f9fc; + color: #1f2933; + border-color: #d9e3ea; +} + +.o_dss_phone_center__calendar_grid button.selected { + border-color: #2a9d8f; + box-shadow: 0 0 0 1px #2a9d8f; +} + +.o_dss_phone_center__calendar_grid button.inactive { + background: #f2f4f6; + color: #a0aab3; +} + +.o_dss_phone_center__calendar_grid .day_no { + font-weight: 700; +} + +.o_dss_phone_center__calendar_grid .day_count { + margin-top: 2px; + font-size: 10px; +} + +.o_dss_phone_center__event_form { + display: grid; + gap: 6px; + grid-template-columns: 1fr; + overflow: auto; +} + +.o_dss_phone_center__event_form textarea { + min-height: 110px; + resize: vertical; +} + +@media (max-width: 1400px) { + .o_dss_phone_center__topbar, + .o_dss_phone_center__topbar--callcenter, + .o_dss_phone_center__topbar--callcenter_bottom { + grid-template-columns: 1fr; + } + + .o_dss_phone_center__layout { + grid-template-columns: 1fr; + grid-template-rows: auto auto; + } + + .o_dss_phone_center__col--left { + grid-template-rows: minmax(220px, auto) minmax(220px, auto); + } + + .o_dss_phone_center__browser_toolbar { + grid-template-columns: 1fr; + } + + .o_dss_phone_center__inline_fields { + grid-template-columns: 1fr; + } + + .o_dss_phone_center__calendar_toolbar--top, + .o_dss_phone_center__calendar_split { + grid-template-columns: 1fr; + } + + .o_dss_phone_center__iframe { + min-height: 380px; + } + + .o_dss_phone_center__map_overlay { + width: calc(100% - 20px); + right: 10px; + left: 10px; + height: 44%; + } +} diff --git a/static/src/scss/style.scss b/static/src/scss/style.scss index c2b485d..501b582 100755 --- a/static/src/scss/style.scss +++ b/static/src/scss/style.scss @@ -98,7 +98,8 @@ .screenview_main { background-color:#000000; - padding:20px; + padding:0; + position:relative; float:left; } @@ -115,6 +116,53 @@ height:100%; } + .screenview_img_placeholder { + background-color: #fff8cc; + color: #2f2a1c; + text-align: center; + padding: 6px; + border: 1px solid #e8df9a; + position: relative; + } + + .screenview_img_placeholder_text { + font-size: 12px; + line-height: 1.2; + font-weight: 600; + word-break: break-word; + white-space: pre-wrap; + } + + .screenview_field_name { + position: absolute; + top: 4px; + left: 4px; + font-size: 10px; + font-weight: 700; + color: #2f2a1c; + background-color: rgba(255, 248, 204, 0.8); + padding: 2px 4px; + border-radius: 2px; + pointer-events: none; + max-width: 80%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .screenview_field_size { + position: absolute; + bottom: 4px; + left: 4px; + font-size: 10px; + font-weight: 700; + color: #2f2a1c; + background-color: rgba(255, 248, 204, 0.8); + padding: 2px 4px; + border-radius: 2px; + pointer-events: none; + } + .o_dark_panel { background-color: #000000; position: fixed; diff --git a/static/src/xml/dss_screenview_controller.xml b/static/src/xml/dss_screenview_controller.xml index e053e75..a6fb7c4 100755 --- a/static/src/xml/dss_screenview_controller.xml +++ b/static/src/xml/dss_screenview_controller.xml @@ -3,6 +3,27 @@ + +
+
+ + +
+ + +
+
diff --git a/static/src/xml/dss_screenview_renderer.xml b/static/src/xml/dss_screenview_renderer.xml index d6092e2..de620ac 100755 --- a/static/src/xml/dss_screenview_renderer.xml +++ b/static/src/xml/dss_screenview_renderer.xml @@ -5,20 +5,29 @@
- - + + + -
- - -
-
-
-
-
+
+ +
+ + + +
+ +
+ +
+
+
+
+
-
+
+
diff --git a/static/src/xml/dss_trigger_condition_view_renderer.xml b/static/src/xml/dss_trigger_condition_view_renderer.xml index d2f768f..5cf3753 100755 --- a/static/src/xml/dss_trigger_condition_view_renderer.xml +++ b/static/src/xml/dss_trigger_condition_view_renderer.xml @@ -44,6 +44,8 @@ + +
diff --git a/static/src/xml/form_button.xml b/static/src/xml/form_button.xml index 47db1c2..48bdd9c 100755 --- a/static/src/xml/form_button.xml +++ b/static/src/xml/form_button.xml @@ -5,6 +5,7 @@ +
+ + + + + + +
+
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
+ AI Agent schreibt... +
+ + + + +
+
+
+ diff --git a/static/src/xml/phone_center_action.xml b/static/src/xml/phone_center_action.xml new file mode 100755 index 0000000..d1371ed --- /dev/null +++ b/static/src/xml/phone_center_action.xml @@ -0,0 +1,351 @@ + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ + + Naechste Suche erst ab Eintraegen in der Liste moeglich. +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+
Kontaktinformationen
+
+
Firma
+
Name
+
+ Telefon + + + + + + - +
+
Email
+
+
Ort
+
Adresse
+
Rating
+
Quelle
+
+
+ Analyse +

+

+ +
+ Verifiziert via am . +
+
+
+
Kontakt klassifizieren
+
+ + + + + +
+
+ LLM-Einschaetzung +

+

+ + + +
+
+ +
+
+ +
+
+
+
+ Recherche + Kalenderansicht +
+ + +
+
+
+ + +
+
+ + + + +
+
+
+ + +
+
+ Maps + +
+ +
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + +
+
+ + + +
+
+
+
+ Termin-Details + + +
+
+ + + + + + + + +
+
+
+
+ + + + +
+
+
+
+ +
+
+
Warum kein guter Kontakt?
+ +
+ + +
+
+
+ +
+
+
Kein Interesse: Wer war der Anrufpartner?
+ +
+ + +
+
+
+ +
+
+
Wiedervorlage planen
+ + + + + + +
+ + +
+
+
+
+
+
diff --git a/temp/temp.temp b/temp/temp.temp index 2b7b2b0..c3ddbf5 100755 Binary files a/temp/temp.temp and b/temp/temp.temp differ diff --git a/testfile.ipynb b/testfile.ipynb new file mode 100755 index 0000000..1254dd0 --- /dev/null +++ b/testfile.ipynb @@ -0,0 +1,55 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "16d67fa4", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "from diffusers import FluxPipeline\n", + "from huggingface_hub import login as hf_login\n", + "\n", + "print(\"Setup ...\")\n", + "hf_login(token=\"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\")\n", + "pipe = FluxPipeline.from_pretrained(\"black-forest-labs/FLUX.1-dev\", torch_dtype=torch.bfloat16)\n", + "pipe.enable_model_cpu_offload() #save some VRAM by offloading the model to CPU. Remove this if you have enough GPU power\n", + "\n", + "prompt = \"A cat holding a sign that says hello world\"\n", + "print(\"Generating image ...\")\n", + "image = pipe(\n", + " prompt,\n", + " height=1024,\n", + " width=1024,\n", + " guidance_scale=3.5,\n", + " num_inference_steps=50,\n", + " max_sequence_length=512,\n", + " generator=torch.Generator(\"cpu\").manual_seed(0)\n", + ").images[0]\n", + "image.save(\"flux-dev.png\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/views/adportal_dashboard.xml b/views/adportal_dashboard.xml new file mode 100755 index 0000000..a1d83f5 --- /dev/null +++ b/views/adportal_dashboard.xml @@ -0,0 +1,1079 @@ + + + + + + + + + + + +