+
+
+
+ This is a wonderful recipe! It's almost exactly like the Chicken Marsala at my favorite restaurant. I make this when I want a delicious, elegant meal and serve it with wild rice and a nice bottle of wine. I always triple the sauce, which is easily done using an 8 oz carton of cream and 1 cup of marsala wine, leaving out the milk. Also, I use double the mushrooms and I use sweet onions instead of green. I simmer it longer till the sauce reduces and thickens to my liking. The cream is the key in this recipe, I can't imagine a chicken marsala recipe without cream in the sauce. I love to make this for guests, it gets raves and I look like a gourmet cook!
+
+
+ Read More
+
+
+
+
+ Good basic recipe, however it's better to pound the chicken first-I put it in a plastic ziplock bag and pound it until thin- dredge in flour(I add garlic powder to the flour)cook in olive oil and butter until golden brown, add the marsala followed by the cream(I use half n half). I never use the onions it changes the flavor too much. I do use roasted garlic sea salt and lemon pepper for seasoning. This recipe calls for equal parts of marsala and cream but in my opinion it needs more marsala and much less cream for a caramel color and richer flavor. With the bits from the cooked chicken and flour the sauce cooks down and thickens. Add capers and voila! I double the sauce and serve over angel hair pasta. Keep in mind it needs to be served right away. Buen Apetito! =^..^=
+
+
+ Read More
+
+
+
+
+ This is a wonderful recipe! It's almost exactly like the Chicken Marsala at my favorite restaurant. I make this when I want a delicious, elegant meal and serve it with wild rice and a nice bottle of wine. I always triple the sauce, which is easily done using an 8 oz carton of cream and 1 cup of marsala wine, leaving out the milk. Also, I use double the mushrooms and I use sweet onions instead of green. I simmer it longer till the sauce reduces and thickens to my liking. The cream is the key in this recipe, I can't imagine a chicken marsala recipe without cream in the sauce. I love to make this for guests, it gets raves and I look like a gourmet cook!
+
+
+ Read More
+
+
+
+
+ Good basic recipe, however it's better to pound the chicken first-I put it in a plastic ziplock bag and pound it until thin- dredge in flour(I add garlic powder to the flour)cook in olive oil and butter until golden brown, add the marsala followed by the cream(I use half n half). I never use the onions it changes the flavor too much. I do use roasted garlic sea salt and lemon pepper for seasoning. This recipe calls for equal parts of marsala and cream but in my opinion it needs more marsala and much less cream for a caramel color and richer flavor. With the bits from the cooked chicken and flour the sauce cooks down and thickens. Add capers and voila! I double the sauce and serve over angel hair pasta. Keep in mind it needs to be served right away. Buen Apetito! =^..^=
+
+
+ Read More
+
+
+
+
+ Our catering business needed an easy, quick Chicken recipe. This one was a hit and the automatic conversion to serve 100 was great. We grilled the chicken the day before the event. Next day we made the sauce (the smell was incredible), poured it over the chicken and baked for about one hour at 350 until warmed through. This recipe looked impressive and served beautifully from chaffing dishes. We'll definitely do this again!
+
+
+ Read More
+
+
+
+
+ I love this recipe. I doubled wine and eliminated the cream and milk and replaced with evaporated milk (also doubled). I used 8 oz. of mushrooms and served the sauce over linguine. The flavor is fantastic. This is a great meal to serve guests!
+
+
+ Read More
+
+
+
+
+ Is it impossible to make an easy gourmet dinner in a flash during the weekday? Not with this recipe!!! Wow! That is all I have to say. Again, I am a purist and usually do the recipes as they are written the first time around. The recipe was definitely very good as is and deserves all the stars. If and only if you decide to “augment” it, here are my suggestions:
+ 1- Lightly dredge the chicken in flour before sautéing it. It gives it a little crust which I like.
+ 2- Remove the chicken from the skillet and reserve in a plate before step #2, and only put back the chicken after adding the cream in step #3. This will ensure that the chicken is still tender.
+ 3- Triple the sauce (i.e. 1 cup of Marsala and 1 cup of cream)!!! It is the best attribute of the recipe! Since there will already be a lot of cream, you don’t need the milk. I just omit it now.
+ 4- Make sure you take the time to cook down the Marsala wine, which is why it should be boiled for 2 to 4 minutes as per step #3. Marsala is a nice fortified wine but can be a little overpowering for kids and people that don’t especially appreciate alcohol.
+ That being said, I’ve served this over pasta, rice, and mashed vegetables and I’ve only had rave reviews.
+ Thanks Sal!
+
+
+ Read More
+
+
+
+
+ Very tasty - the cream makes the difference! Modifications I made...(1) pounded the chicken breasts to 1/4 inch thickness as is traditionally done with chicken marsala; and (2)after cooking the chicken over MEDIUM heat, I put it aside on a plate while cooking the mushrooms to avoid over cooking. Then added the chicken back in before adding the marsala wine.
+
+
+ Read More
+
+
+
+
+ Love it, love it! I too double or triple the sauce so that we can have extra for pasta. The only change I made, was to use evaporated milk instead of cream. Every little bit of calorie and fat cutting counts. In this case as in many when you use the evaporated milk in place of creams and regular milk products, you get all of the creaminess and not so much of the fat. It is also a real $$ saver. If you have champagne taste but only have beer budget, you will like this substitute. I promise!
+
+
+ Read More
+
+
+
+
+ The same night I made this Chicken Marsala for family, I also made a different one from this site for me. In a side by side taste test, this one was definitely richer because of the cream. The one I made for myself had no cream. The flavor on both was way out of this world. Because of the cream sauce, the presentation was prettier on this one. I will use this recipe for guests and the other Marsala for my family dinners. Both recipes are perfectly flavored.Thanks for sharing Sara.
+
+
+ Read More
+
+
+
+
+ Loved it! Chicken Marsala is one of my favorite dishes and I have been searching for a good recipe. My husband loved too. This was easy and tasted great. I tripled the sauce and it was perfect.
+
+
+ Read More
+
+ *Percent Daily Values are based on a 2,000 calorie diet. Your daily values may be higher or lower depending on your calorie needs.
+
+
+ **Nutrient information is not available for all ingredients. Amount is based on available nutrient data.
+
+
+ (-)Information is not currently available for this nutrient. If you are following a medically restrictive diet, please consult your doctor or registered dietitian before preparing this recipe for personal consumption.
+
+
+
+ This is a wonderful recipe! It's almost exactly like the Chicken Marsala at my favorite restaurant. I make this when I want a delicious, elegant meal and serve it with wild rice and a nice bottle of wine. I always triple the sauce, which is easily done using an 8 oz carton of cream and 1 cup of marsala wine, leaving out the milk. Also, I use double the mushrooms and I use sweet onions instead of green. I simmer it longer till the sauce reduces and thickens to my liking. The cream is the key in this recipe, I can't imagine a chicken marsala recipe without cream in the sauce. I love to make this for guests, it gets raves and I look like a gourmet cook!
+
+
+
+
+ Good basic recipe, however it's better to pound the chicken first-I put it in a plastic ziplock bag and pound it until thin- dredge in flour(I add garlic powder to the flour)cook in olive oil and butter until golden brown, add the marsala followed by the cream(I use half n half). I never use the onions it changes the flavor too much. I do use roasted garlic sea salt and lemon pepper for seasoning. This recipe calls for equal parts of marsala and cream but in my opinion it needs more marsala and much less cream for a caramel color and richer flavor. With the bits from the cooked chicken and flour the sauce cooks down and thickens. Add capers and voila! I double the sauce and serve over angel hair pasta. Keep in mind it needs to be served right away. Buen Apetito! =^..^=
+
+
+
+
+ Our catering business needed an easy, quick Chicken recipe. This one was a hit and the automatic conversion to serve 100 was great. We grilled the chicken the day before the event. Next day we made the sauce (the smell was incredible), poured it over the chicken and baked for about one hour at 350 until warmed through. This recipe looked impressive and served beautifully from chaffing dishes. We'll definitely do this again!
+
+
+
+
+ I love this recipe. I doubled wine and eliminated the cream and milk and replaced with evaporated milk (also doubled). I used 8 oz. of mushrooms and served the sauce over linguine. The flavor is fantastic. This is a great meal to serve guests!
+
+
+
+
+ Is it impossible to make an easy gourmet dinner in a flash during the weekday? Not with this recipe!!! Wow! That is all I have to say. Again, I am a purist and usually do the recipes as they are written the first time around. The recipe was definitely very good as is and deserves all the stars. If and only if you decide to “augment” it, here are my suggestions:
+ 1- Lightly dredge the chicken in flour before sautéing it. It gives it a little crust which I like.
+ 2- Remove the chicken from the skillet and reserve in a plate before step #2, and only put back the chicken after adding the cream in step #3. This will ensure that the chicken is still tender.
+ 3- Triple the sauce (i.e. 1 cup of Marsala and 1 cup of cream)!!! It is the best attribute of the recipe! Since there will already be a lot of cream, you don’t need the milk. I just omit it now.
+ 4- Make sure you take the time to cook down the Marsala wine, which is why it should be boiled for 2 to 4 minutes as per step #3. Marsala is a nice fortified wine but can be a little overpowering for kids and people that don’t especially appreciate alcohol.
+ That being said, I’ve served this over pasta, rice, and mashed vegetables and I’ve only had rave reviews.
+ Thanks Sal!
+
+
+
+
+ Very tasty - the cream makes the difference! Modifications I made...(1) pounded the chicken breasts to 1/4 inch thickness as is traditionally done with chicken marsala; and (2)after cooking the chicken over MEDIUM heat, I put it aside on a plate while cooking the mushrooms to avoid over cooking. Then added the chicken back in before adding the marsala wine.
+
+
+
+
+ Love it, love it! I too double or triple the sauce so that we can have extra for pasta. The only change I made, was to use evaporated milk instead of cream. Every little bit of calorie and fat cutting counts. In this case as in many when you use the evaporated milk in place of creams and regular milk products, you get all of the creaminess and not so much of the fat. It is also a real $$ saver. If you have champagne taste but only have beer budget, you will like this substitute. I promise!
+
+
+
+
+ The same night I made this Chicken Marsala for family, I also made a different one from this site for me. In a side by side taste test, this one was definitely richer because of the cream. The one I made for myself had no cream. The flavor on both was way out of this world. Because of the cream sauce, the presentation was prettier on this one. I will use this recipe for guests and the other Marsala for my family dinners. Both recipes are perfectly flavored.Thanks for sharing Sara.
+
+
+
+
+ Loved it! Chicken Marsala is one of my favorite dishes and I have been searching for a good recipe. My husband loved too. This was easy and tasted great. I tripled the sauce and it was perfect.
+
+
Lightly browning chicken breast strips in butter started building flavor into our pasta with chicken recipe. We kept the chicken tender and added more flavor by letting the strips finish cooking in the sauce, and we kept the broccoli fresh ...
Be sure to use low-sodium chicken broth in this recipe; regular chicken broth will make the dish extremely salty. The broccoli is blanched in the same water that is later used to cook the pasta. Remove the broccoli when it is tender at the edges but still crisp at the core-it will continue to cook with residual heat. If you can't find Asiago cheese, Parmesan is an acceptable alternative.
+
1
INSTRUCTIONS
Bring 4 quarts water to rolling boil, covered, in stockpot.
+
2
Meanwhile, heat 1 tablespoon butter in 12-inch nonstick skillet over high heat until just beginning to brown, about 1 minute. Add chicken in single layer; cook for 1 minute without stirring, then stir chicken and continue to cook until most, but not all, of pink color has disappeared and chicken is lightly browned around the edges, about 2 minutes longer. Transfer chicken to clean bowl; set aside.
+
3
Return skillet to high heat and add 1 tablespoon butter; add onion and 1/4 teaspoon salt and cook, stirring occasionally, until browned about edges, 2 to 3 minutes. Stir in garlic, red pepper flakes, thyme, and flour; cook, stirring constantly, until fragrant, about 30 seconds. Add wine and chicken broth; bring to simmer, then reduce heat to medium and continue to simmer, stirring occasionally, until sauce has thickened slightly and reduced to 1 1/4 cups, about 15 minutes.
+
4
While sauce simmers, add 1 tablespoon salt and broccoli to boiling water; cook until broccoli is tender but still crisp at center, about 2 minutes. Using slotted spoon, transfer broccoli to large paper towel-lined plate. Return water to boil; stir in pasta and cook until al dente. Drain, reserving 1/2 cup pasta cooking water; return pasta to pot.
+
5
Stir remaining 2 tablespoons butter, Asiago, sun-dried tomatoes, parsley, and chicken into sauce in skillet; cook until chicken is hot and cooked through, about 1 minute. Off heat, season to taste with pepper. Pour chicken/sauce mixture over pasta and add broccoli; toss gently to combine, adding pasta cooking water as needed to adjust sauce consistency. Serve immediately, passing additional Asiago and the lemon wedges (if using) separately.
+
Like this recipe? , or leave a comment below.
FROM OUR TV SPONSORS
We are thankful to the sponsors who make it possible for us to bring you the America's Test Kitchen TV series on public television. Read more about why we have sponsors.
+ Petersilie, Koriander und Minze, frisch und fein geschnitten
+
+
+
+
+ 1
+
+ Zitrone(n), Bio
+
+
+
+
+
+ Salz und Pfeffer, Ras el-Hanout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Zubereitung
+
+ Arbeitszeit ca. 15 Minuten
+
+ Koch-/Backzeit ca. 25 Minuten
+
+ Gesamtzeit ca. 40 Minuten
+
+
+ Wasser, Gemüsebrühe, Kurkuma und Öl erhitzen. Couscous unter Rühren zufügen und quellen lassen, bis er al dente ist.
+
+Gemüse putzen. Zucchini vierteln und in Scheiben, Paprika entkernen und das Fruchtfleisch in Streifen schneiden. Getrocknete Tomaten fein würfeln, Frühlingszwiebeln in feine Ringe schneiden.
+
+Alles vermengen und mit gepresstem Knoblauch, Zitrone, den Kräutern, Salz, Pfeffer und Ras el-Hanout würzen bzw. abschmecken.
+
+4 Blätter Pergament ausbreiten, etwas Couscous darauf geben, das Gemüse gleichmäßig darüber verteilen und die Gambas obenauf setzen. Diese noch nach Geschmack mit Salz und Pfeffer würzen und das Pergament verschließen.
+
+Im vorgeheizten Backofen bei 180°C Umluft ca. 20-25 Minuten garen.
+
+Anrichten: Couscous, Gemüse und Garnelen im Pergament auf einen Teller legen, das Pergament oben etwas öffnen, und aus dem Pergament heraus genießen! Guten Appetit!
+
Hallo!
+
+Ich habe das Gericht vorgestern ausprobiert, hat mir sehr gut geschmeckt- Nächstes Mal lasse ich die Päckchen (ich habe übrigens normales Backpapier verwendet) kürzer im Ofen, 22 Minuten waren mir für die Garnelen etwas zu lange. Außerdem werde ich etwas weniger Wasser für den Couscous verwenden, ich mag ihn lieber etwas trockener.
+
+Schönes Gericht, geht auch rasch, koche ich gerne wieder nach! Danke fürs Rezept und LG, Bali-Bine
Wirklich tolles Rezept. Zum Ausprobieren habe ich eine Portion in Pergament gepackt, falls ich es mal für Gäste einsetze.
+Ich hatte bei der Backzeit Angst, dass die Garnelen hart werden. Die war aber unbegründet, die Dingerchen waren auch nach 25 Minuten im Ofen noch genau richtig.
+
+Der Hauptteil ging in meine Terrinenform mit Deckel, die für 2 Personen locker ausreicht. Das Ergebnis war sowohl mit Pergament wie auch mit der Terrine perfekt.
Hallo karolinka1985,
+
+es handelt sich dabei um eine marokkanische Gewürzmischung, die in gut sortierten Supermärkten, ansonsten aber auch in Reformhäusern oder Asia Läden erhältlich ist. Sie enthält u.a. Muskat, Zimt, Anis, Pfeffer, Nelken, Piment, Kardamom, Ingwer und Chili und eignet sich hervorragend, um dem Ganzen das besondere Etwas zu verleihen!
+
+Viele Grüße,
+Mandy Scheffel / chefkoch.de
Hallo,
+
+das Gericht klingt sehr lecker. Eine Frage habe ich aber: Ist mit "1 EL Gemüsebrühe " die gekoernte Bruehe, also das Pulver, gemeint?
+
+Gruss Dorry
Hallo Dorry,
+
+ja, damit ist gekörnte Brühe gemeint. Du kannst den Couscous selbstverständlich auch in selbst gemachter Brühe quellen lassen.
+
+Viele Grüße,
+
+Mandy Scheffel / chefkoch.de
+
+ Arbeitszeit ca. 25 Minuten
+
+ Gesamtzeit ca. 25 Minuten
+
+
+ Der Brokkoli wird in kleine Stücke geschnitten, möglichst nicht zermantschen. Das Eigelb wird mit der Speisestärke vermischt und dem Brokkoli beigefügt. Das Eiweiß wird zu Schnee geschlagen, danach wird der Käse mit dem Eiweiß vermengt und das Eiweiß-Käse-Gemisch zum Brokkoli gegeben, ebenso die Sonnenblumenkerne. Mit den angegebenen Gewürzen bestreuen und mit einem Teigschaber gut vermischen. Wenn der Teig sehr nass ist, dann kann man noch etwas Paniermehl oder auch Haferflocken zugeben.
+
+Aus dem Teig lassen sich ca. 12 Bratlinge formen, diese werden in Paniermehl gewälzt und dann in der Pfanne von beiden Seiten solange gebraten, bis sie braun sind.
+
+Dazu passt (Kräuter-)Baguette. Oder man serviert es dem vegetarisch essenden Teil der Familie, während die anderen Frikadellen bekommen. Diese Bratlinge eignen sich zum Einfrieren.
+
Diese Bratlinge ziehe ich jedem Fleischküchle vor, obwohl ich keine Vegetarierin bin!! Habe dem Teig einige feine Haferflocken hinzugefügt! Wir essen gerne Brokkoli, dieses Rezept hat 5 Sterne verdient!
Diese Bratlinge sind der Hit- total lecker! Da verpackt man den Brokkoli so schmackhaft, dass das Gesunde nicht auffällt. Ich habe auch die Stengel mitverwendet, kein Problem. Dieses Gericht kam bei uns inzwischen schon öfters auf den Tisch. Auch kalt sind die Bratlinge sehr lecker.
+Vielen Dank für dieses Rezept!
mega lecker, wirds bei uns definitiv jetzt öfters geben.
+
+Anmerken muss ich noch dass ich anfangs keine Chance hatte Bratlinge zu formen bis ich eine ordentliche Ladung Haferflocken dazugegeben habe. danach war es absolut kein Probem mehr Bratlinge zu formen und diese im Paniermehl zu wälzen 👍
Hallo,
+ich habe die Bratlinge als kleines Mittagessen mit Salat gemacht und war begeistert.
+Da ich momentan sehr oft Brokkoli in meiner Gemüsekiste habe war ich auf der Suche nach einem Rezept das mal was anderes ist. Das hab ich gefunden, die Bratlinge wird es sicher öfter geben.
+Ich habe einen sehr würzigen schweizer Emmentaler verwendet und so haben die Bratlinge einen sehr schönen Geschmack bekommen. Mit einem weniger würzigen Käse würde ich es nicht versuchen.
+Zusätzlich habe ich noch eine große Zehe Knoblauch in die Masse gegeben, aber das ist sicher Geschmackssache, ich liebe Knoblauch einfach.
+Das Rezept ist einfach, alles klappt, die Mengenangaben passen.
+Vielen Dank dafür!
+LG Italobavari
Hallo,
+
+ich musste der Menge noch ein Ei hinzufuegen sonst haette die Masse nicht zusammengehalten und anstelle Emmentaler gabs je zur haelfte geriebenen Schafskaese und Kefalotyrikaese. Vor dem Braten habe ich die Bratlinge noch in ein wenig Mehl gewaelzt. Lecker. Sie schmecken auch kalt.
+
+Danke und LG Gabi
Hallo Gabi,
+
+mit dem Emmentaler wird die Masse sämig, dass kann ein Schafskäse nicht. Von daher würde ich empfehlen, den Schafskäse zusätzlich zu dem Emmentaler zu verwenden, aber nicht zu ersetzen, damit die Bindung auch gewährleistet ist.
+
+LG
+
+eskima
+ Try this soup with Moringa / Malunggay pods, also called "drumsticks." These were picked right from our yard. :)
+#veggies#moringaoleifera
+
+
+
+
+
+
+
+
+
+
+
+30 mins
+
+
+
+
+
+
Ingredients
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Chicken cuts
+
+
+
+
+
+ Moringa or Malunggay pods, peeled and split
+
+
+
+
+
+ 1 chicken broth cube
+
+
+
+
+
+ 2-3 c Water
+
+
+
+
+
+ 1 small onion, chopped
+
+
+
+
+
+ 2 garlic cloves, chopped
+
+
+
+
+
+ 1 thumb ginger
+
+
+
+
+
+ to taste Salt & pepper
+
+
+
+
+
+ Cooking oil
+
+
+
+
+
+
+
+
+
+
+
Steps
+
+
+
+
+
+
+
+
Sauté the onions, garlic and ginger in oil until fragrant.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add the peeled Moringa pods, with seeds. You can search the net on how to peel the pods. Forgot to take a photo of it, just remove the hard skin with a knife.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add the chicken pieces, a few tbsp water, sauté the cover. Let the juices of chicken come out.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dissolve the chicken broth cube in water then add to your pot.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Cook until Moringa pods are tender. Taste and season accordingly. Delicious to eat and pour soup over rice.
+ A cooking journal of a beach bum, a travel blogger & a weight-conscious Foodie. These recipes are either discovered, invented, copycatted, passed down or passed on. Do let me know what you think, post a photo too! 😊 IG@SpottedByD
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Cooksnaps
+
+
+
+
+
+
+
Did you make this recipe? Share a picture of your creation!
Shrimp Piccata Pasta sounds complicated, but this elegant dish is actually easy to assemble and serve-even on a weeknight.
SERVES4
WHY THIS RECIPE WORKS
To prepare our Shrimp Piccata Pasta, we seared the shrimp over high heat until just barely cooked through, then set them aside until the sauce was prepared. We used just one pan for cooking both the shrimp and the sauce so the sauce picked ...
Be sure to toss the shrimp and sauce with the pasta immediately after draining. The hot pasta will heat the shrimp and melt the butter.
+
1
INSTRUCTIONS
Bring 4 quarts water to boil in pot for cooking pasta. Meanwhile, heat 1 tablespoon oil in large skillet over high heat. Add shrimp and cook, stirring, until just opaque, about 1 minute. Transfer to large plate. Heat remaining tablespoon oil in empty skillet over medium heat. Add garlic and pepper flakes and cook until fragrant but not browned, about 30 seconds. Add wine, increase heat to high, and simmer until liquid is reduced and syrupy, about 2 minutes. Add clam broth and lemon juice, bring to boil, and cook until mixture is reduced to 1/3 cup, about 8 minutes.
+
2
As the sauce cooks, add 1 tablespoon salt and pasta to boiling water and cook until al dente. Reserving 1/2 cup cooking water, drain pasta, then transfer to large serving bowl. Toss with sauce, shrimp, capers, parsley, and butter until butter melts and shrimp is warmed through. (Add reserved cooking water if sauce seems dry.) Adjust seasonings with salt and pepper. Serve.
+
Like this recipe? , or leave a comment below.
FROM OUR TV SPONSORS
We are thankful to the sponsors who make it possible for us to bring you the Cook’s Country TV series on public television. Read more about why we have sponsors.
During the spring and summer, there's so much asparagus, we don't even know how to handle it. We love it simply roasted, but there's so much more to do with the vegetable: You can make asparagus bacon pasta, bacon asparagus roll-ups, or asparagus soup. But our favorite way to prepare it as a fancy side: Top it with garlic, a little cream, Parmesan, and mozzarella and bake it until the cheese is bubbly and golden and the asparagus is tender. This will be gone in seconds.
+ This ingredient shopping module is created and maintained by a third party, and imported onto this page. You may be able to find more information about this and similar content on their web site.
+
+
+
+
+
+
+
+
Directions
+
+
+
+
+
+
+
+
Preheat oven to 400º. Place asparagus in a 9"-x-13" baking dish and pour over heavy cream and scatter with garlic. Generously season with salt and pepper, then sprinkle with Parmesan, mozzarella and red pepper flakes (if using).
Bake until cheese is golden and melty and asparagus is tender, about 25 to 30 minutes, and serve.
+
+
+
+
+
+
+
Nutrition (per serving): 250 calories, 14 g protein, 8 g carbohydrates, 3 g fiber, 3 g sugar, 19 g fat, 11 g saturated fat, 340 mg sodium
+
+
+
+ This content is imported from {embed-name}. You may be able to find the same content in another format, or you may be able to find more information, at their web site.
+
Lindsay Funston
+Executive Editor
+ Lindsay Funston is a food editor who has more than 10 years experience tasting everything from pickles to bloody marys, writing about food trends, and creating easy recipes.
+
+
+
+
+
+ This content is created and maintained by a third party, and imported onto this page to help users provide their email addresses. You may be able to find more information about this and similar content at piano.io
+
+ Heat grill to high. Brush potatoes halves, onion slices, peppers, and chiles with oil and season with salt and pepper, to taste. Grill potatoes and onions for 2 to 3 minutes per side or until just cooked through and slightly charred. Remove from heat, cut each potato half in half again, and finely chop the onions.
+
+
+
+ Grill peppers and chiles until charred on all sides. Remove from the grill, place in a bowl, cover, and let steam for 5 minutes. Remove skin and finely dice.
+
+
+
+ Melt butter in a 9-inch cast iron skillet on the grates of the grill. Add the potatoes, onions, peppers, and chiles all in 1 layer and pack down. Cook until crisp and nicely browned.
+
Gli strangolapreti alla trentina sono una ricetta davvero antica, si tratta di gnocchi davvero speciali a base di pane raffermo e spinaci. Lo chef Alessandro Gilmozzi ci mostrerà come dare forma a questi morbidi coni, perfetti da servire come primo piatto. La ricetta storica prevedeva molto più pane e meno spinaci. Poi si è evoluta tanto da prevedere varianti e piccoli segreti che ogni famiglia e ogni ristoratore conserva per la realizzazione degli strangolapreti, come scoprirete dalla ricetta dello chef. Grazie alla ricchezza delle piante ed erbe del Trentino, una volta (ma spesso ancora oggi) gli spinaci venivano sostituiti con ortiche, portulaca o solo con erbette e bietole. Una base minore di spinaci poteva essere aromatizzata con erbe come dragoncello, maggiorana e origano. Gli strangolapreti vengono conditi con burro e salvia, proprio come propone Alessandro Gilmozzi, ma potrete aggiungere anche speck o pancetta.
Per immergervi totalmente nella magica atmosfera trentina, scoprite anche canederli e spatzle, altri due tipici primi piatti!
+ Come preparare gli Strangolapreti alla trentina
+
+
+
+
+
+
+
+
+
+
+
+
Per preparare gli strangolapreti alla trentina come prima cosa cuocete gli spinaci. Ve ne serviranno 650 freschi per ottenerne 300 g cotti e molto ben strizzati. Se usate spinaci freschi potete sbollentarli in acqua leggermente salata per qualche minuto. Se utilizzate quelli in busta potete cuocerli al vapore. Ripulite i panini dalla crosta esterna (potrete usarla come indicato nel consiglio in fondo alla ricetta!) e tagliate la mollica che servirà per gli strangolapreti: dovrete ottenere dei cubetti di 1 cm. Trasferiteli all'interno di una ciotola 1, unite circa metà dose di latte 2 e l'olio 3.
+
+
+
+
+
+
+
+
+
+
+
+
Mescolate leggermente 4 e tenete da parte. Versate gli spinaci strizzati all'interno di un contenitore stretto e alto 5, aggiungete il latte rimasto 6.
+
+
+
+
+
+
+
+
+
+
+
+
Unite anche le uova 7 e regolate di sale 8 e pepe. Aggiungete anche la noce moscata 9
+
+
+
+
+
+
+
+
+
+
+
+
e frullate il tutto con un minipimer 10 fino ad ottenere una crema liscia 11. Versate quindi la crema di spinaci all'interno della ciotola con il pane 12
+
+
+
+
+
+
+
+
+
+
+
+
e mescolate con un mestolo di legno 13. Aggiungete la farina 14 e il pangrattato 15.
+
+
+
+
+
+
+
+
+
+
+
+
Mescolate ancora con il mestolo 16, poi terminate l'impasto lavorandolo brevemente con le mani anche per sentirne la consistenza 17. Lasciatelo riposare qualche minuto. Inumidite due cucchiai 18
+
+
+
+
+
+
+
+
+
+
+
+
e prelevate una porzione d'impasto, circa mezzo cucchiaio 19. Trasferite il primo mucchietto su un canovaccio ben infarinato 20 e proseguite in questo modo per tutti gli altri 21. Si andranno a formare delle quenelle.
+
+
+
+
+
+
+
+
+
+
+
+
Spolverizzate con poca farina i mucchietti e lavoratene uno ad uno con le mani 22, in modo da arrotondarli leggermente come fossero canederli, ma schiacciando leggermente per creare una forma conica 23. Ogni tanto infarinatevi bene le mani, in questo modo sarà più facile lavorare il composto e terranno meglio la cottura. Se preferite potete realizzare la classica forma a quenelle lavorando il composto solo con i due cucchiai inumiditi 24. Poi dovrete comunque leggermente spolverizzare con la farina.
+
+
+
+
+
+
+
+
+
+
+
+
Mettete sul fuoco due tegami con dell'acqua, che servirà per la cottura degli gnocchi. Salate l'acqua e non appena inizierà a sobbollire tuffate pochi gnocchi alla volta 25. Dato che la cottura è delicata, per cuocerli tutti insieme serviranno almeno due tegami. Se riducete le dosi o avete modo di cuocerli un po' alla volta potete anche utilizzarne uno solo. Nel frattempo preparate anche il condimento. In un tegame aggiungete burro e salvia 26, lasciate fondere il burro e insaporire le foglioline 27.
+
+
+
+
+
+
+
+
+
+
+
+
Non appena gli gnocchi saliranno a galla aspettate 2-3 minuti e scolateli 28. In tutto ci vorranno circa 8 minuti. Scolateli e conservateli in una ciotola. Intanto cuocete gli altri. Poi trasferite gli gnocchi su un piatto 29, guarnite con abbondante Trentingrana grattugiato 30
+
+
+
+
+
+
+
+
+
+
+
+
aggiungete abbondante burro fuso 31 e decorate con le foglioline di salvia 32. Servite gli gnocchi alla trentina ancora caldi 33!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Conservazione
+
+
+
+
+
+
L'impasto degli gnocchi si può conservare in frigorifero per un giorno. Una volta cotti consigliamo di servirli subito; in alternativa potete conservarli in frigo per un giorno e scaldarli prima di servirli.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Consiglio
+
+
+
+
+
+
Si consiglia di servire 5-6 pezzi a testa.
Per il pane: scegliete un pane non all'olio, andranno benissimo delle rosette bianche o delle michette.
+
La crosta del pane che andrete ad eliminare non gettatela: tostatela leggermente per realizzare del pangrattato fatto in casa!
+
Ricordate di strizzare molto bene gli spinaci, perchè altrimenti la consistenza dello gnocco risentirebbe dell'eccesso di acqua presente, non risultando ben sodo in cottura.
+
Un piccolo consiglio quando rompete le uova: fatelo sempre sul piano di lavoro e mai direttamente ai bordi della ciotola, padella o contenitore che state utilizzando.
+
Al posto del Trentingrana potete utilizzare del Grana Padano DOP o un formaggio a pasta dura stagionato, stravecchio.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Curiosità
+
+
+
+
+
+
Con il termine strangolapreti (o strozzapreti) si indica anche una pasta fresca a forma di torcioni tipica dell'Emilia Romagna e realizzata solo con acqua e farina.
La ratatouille est un recette d'été délicieuse, qui mettra du soleil dans vos assiettes ! Avec ses légumes, la ratatouille est idéale pour accompagner un barbecue ou des grillades. Découvrez aussi notre recette de ratatouille en vidéo.
+ Lavez et détaillez les courgettes, l'aubergine, le poivron vert et le rouge, en cubes de taille moyenne.
+
+Coupez les tomates en quartiers et émincez l'oignon.
+
+
+ 2
+
+
+
+
+
+
+ Dans une poêle, versez un peu d'huile d'olive et faites-y revenir les uns après les autres les différents légumes pendant 5 minutes pour qu'ils colorent.
+Commencez par les poivrons, puis les aubergines, les courgettes et enfin les oignons et les tomates que vous cuirez ensemble.
+
+
+ 3
+
+
+
+
+
+
+ Après avoir fait cuire les légumes, ajoutez-les tous aux tomates et aux oignons, baissez le feu puis mélangez.
+
+Ajoutez un beau bouquet garni de thym, de romarin et de laurier, salez, poivrez, puis couvrez pour laisser mijoter 40 minutes en remuant régulièrement.
+
+
+ 4
+
+
+
+
+
+
+ À environ 10 minutes du terme de la cuisson, ajoutez les deux belles gousses d'ail écrasées puis couvrez de nouveau.
+N'hésitez pas à goûter et à assaisonner de nouveau selon vos goûts.
+
+
+
+
Pour finir
+
+
+
+
+
+
+ Dégustez avec des grillades ou un barbecue.
+
+
+
+ Cuisinez, savourez… puis si vous le souhaitez, partagez / déposez (ci-dessous) votre avis sur cette recette.
+ excelente recette rien a dire moi j'ai rajouter en fin de cuisson une tranche de dos de cabillaud mais c'est tout aussi bon avec un autre poisson blanc
+ Une excellente recette!!!!! C'est la première ratatouille que je réalise et c'était un véritable délice!!! Ni mon mari ni moi sommes fans de ce plat mais là nous l'avons tout simplement dégusté!!! Il n'a rien laissé!! vraiment merci pour cette succulente recette! une vraie révélation et une réussite pour nous !!!! Un plat onctueux, plein de saveurs.... à refaire très très vite pour la famille!!
+ C'est une ratatouille savoureuse.Les temps de cuisson sont corrects.Cette recette apporte une meilleure cuisson des légumes, par rapport à ma recette habituelle, qui était d'ajouter, au fur et à mesure, les légumes dans le même faitout.Je l'ai préparée plusieurs fois cet été, je la recommande!
+ J avais l habitude de faire la ratatouille comme ma belle mère qui était de Grasse. Puis pendant longtemps j ai oublié ce plat et là de nouveau avec la chaleur de cet été rebelotte et j ai suivi votre recette et mon mari conquis car encore mieux que sa maman... C est dire????
+ Ultra ultra ultra bonne la ratatouille ???? __Ça sent bon dans mon appartement et le goût est succulent ! Je l’accompagne ce soir avec une bonne omelette tout simplement . __Merci pour cette recette ????????
+
+
+
+
+
+
+
+
+
+
diff --git a/cookbook/tests/other/test_data/madamedessert.html b/cookbook/tests/other/test_data/madamedessert.html
new file mode 100644
index 00000000..851b3f3b
--- /dev/null
+++ b/cookbook/tests/other/test_data/madamedessert.html
@@ -0,0 +1 @@
+
diff --git a/cookbook/tests/other/test_data/madamedessert.json b/cookbook/tests/other/test_data/madamedessert.json
new file mode 100644
index 00000000..9865802d
--- /dev/null
+++ b/cookbook/tests/other/test_data/madamedessert.json
@@ -0,0 +1 @@
+{"@context":"https://schema.org","@graph":[{"@type":"WebSite","@id":"https://madamedessert.de/#website","url":"https://madamedessert.de/","name":"Madame Dessert","description":"Der Dessert Blog f\u00fcr Naschkatzen und Schleckerm\u00e4uler \u2013 Rezepte, Inspiration & Lust auf Genuss","potentialAction":[{"@type":"SearchAction","target":"https://madamedessert.de/search/{search_term_string}","query-input":"required name=search_term_string"}],"inLanguage":"de-DE"},{"@type":"ImageObject","@id":"https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/#primaryimage","inLanguage":"de-DE","url":"https://assets.madamedessert.de/wp-content/uploads/2020/02/25163328/Madame-Dessert_Schokopudding-Schokoladenpudding-mit-echter-Schokolade-0238-scaled.jpg","width":2560,"height":1707,"caption":"selbstgemachter schokopudding \u2013 schokoladenpudding rezept mit echter schokolade | Madame Dessert"},{"@type":"WebPage","@id":"https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/#webpage","url":"https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/","name":"Schokoladenpudding Rezept mit echter Schokolade | Madame Dessert","isPartOf":{"@id":"https://madamedessert.de/#website"},"primaryImageOfPage":{"@id":"https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/#primaryimage"},"datePublished":"2020-02-27T06:00:00+00:00","dateModified":"2020-02-27T11:43:42+00:00","author":{"@id":"https://madamedessert.de/#/schema/person/c298fe4e37de6680eb76313190d5ba8f"},"description":"Die besten Rezepte bestehen aus Kindheitserinnerungen & jeder Menge Schokolade \u2013 So wie dieses herrliche Schokoladenpudding Rezept zum Selbermachen.","inLanguage":"de-DE","potentialAction":[{"@type":"ReadAction","target":["https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/"]}]},{"@type":["Person"],"@id":"https://madamedessert.de/#/schema/person/c298fe4e37de6680eb76313190d5ba8f","name":"Eva","image":{"@type":"ImageObject","@id":"https://madamedessert.de/#personlogo","inLanguage":"de-DE","url":"https://secure.gravatar.com/avatar/69c7f5c9580bf693e113bc251c504a5c?s=96&d=monsterid&r=g","caption":"Eva"},"sameAs":["https://www.facebook.com/madamedessert","https://twitter.com/MadameDessert"]},{"@context":"http://schema.org/","@type":"Recipe","name":"Schokoladenpudding Rezept mit echter Schokolade","author":{"@type":"Person","name":"Madame Dessert"},"description":"Die besten Desserts stecken f\u00fcr mich voller Kindheitserinnerungen und jeder Menge Schokolade, so wie dieses Schokoladenpudding Rezept. Au\u00dferdem ist so ein cremiger Schokopudding mit echter Schokolade einfach das perfekte Soulfood.","datePublished":"2020-02-27T07:00:00+00:00","image":["https://assets.madamedessert.de/wp-content/uploads/2020/02/25163328/Madame-Dessert_Schokopudding-Schokoladenpudding-mit-echter-Schokolade-0238-scaled.jpg","https://assets.madamedessert.de/wp-content/uploads/2020/02/25163328/Madame-Dessert_Schokopudding-Schokoladenpudding-mit-echter-Schokolade-0238-500x500.jpg","https://assets.madamedessert.de/wp-content/uploads/2020/02/25163328/Madame-Dessert_Schokopudding-Schokoladenpudding-mit-echter-Schokolade-0238-500x375.jpg","https://assets.madamedessert.de/wp-content/uploads/2020/02/25163328/Madame-Dessert_Schokopudding-Schokoladenpudding-mit-echter-Schokolade-0238-480x270.jpg"],"recipeYield":"6 Portionen","cookTime":"PT20M","recipeIngredient":["170 g hochwertige Zartbitterschokolade (60 \u2013 80% Kakaogehalt)","700 ml Vollmilch","120 ml Sahne","1 gute Prise Salz","1 TL Vanilleextrakt","150 g Zucker","30 g Speisest\u00e4rke","6 Eigelbe (Gr\u00f6\u00dfe L) (bei Raumtemperatur)"],"recipeInstructions":[{"@type":"HowToStep","text":"Hacke die Schokolade fein und stelle sie beiseite."},{"@type":"HowToStep","text":"Gib die Milch zusammen mit der Sahne, etwas Salz, dem Vanilleextrakt und 50g des Zuckers in einen Topf. Koche alles kurz unter gelegentlichem R\u00fchren auf. Reduziere die Hitze anschlie\u00dfend auf eine mittlere Stufe."},{"@type":"HowToStep","text":"W\u00e4hrend die Milch warm wird, vermische den restlichen Zucker mit der St\u00e4rke in einer Sch\u00fcssel. Gib anschlie\u00dfend die Eigelbe dazu und r\u00fchre sie unter."},{"@type":"HowToStep","text":"Gib etwa 1/3 der hei\u00dfen Milch-Mischung zu den Eingelben und r\u00fchre alles glatt. Gie\u00dfe alles zusammen langsam und gleichm\u00e4\u00dfig unter stetigem R\u00fchren zur\u00fcck in den Topf."},{"@type":"HowToStep","text":"Erw\u00e4rme den Pudding unter stetigem R\u00fchren f\u00fcr etwa 3 bis 4 Minuten, bis es einmal kurz aufblubbert und eindickt. Am besten verwendest du hierf\u00fcr einen hitzebest\u00e4ndigen Teigschaber r\u00fchren."},{"@type":"HowToStep","text":"Hat dein Pudding die perfekte Konsistenz erreicht und eine gro\u00dfe Luftblase ist in der Mitte nach oben gestiegen, kannst du den Topf vom Herd nehmen, die Platte ausschalten und die klein gehackte Schokolade unterr\u00fchren. Die Schokolade sollte sich vollst\u00e4ndig aufl\u00f6sen und einen homogenen Pudding bilden. Gie\u00dfe den Schokoladenpudding in eine gro\u00dfe Form oder mehrere kleine Dessert Gl\u00e4ser."},{"@type":"HowToStep","text":"Je nachdem wie du deinen Pudding am liebsten magst \u2013 warm, kalt, mit Haut oder ohne Haut \u2013 liest du dir am besten noch einmal meine Tipps im Rezept auf dem Blog durch."},{"@type":"HowToStep","text":"Macht es euch lecker!Eure Madame Dessert"}],"recipeCategory":["Dessert"],"keywords":"Pudding, Schokolade, Schokoladenpudding, Schokopudding","aggregateRating":{"@type":"AggregateRating","ratingValue":"4.86","ratingCount":"7"},"@id":"https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/#recipe","isPartOf":{"@id":"https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/#webpage"},"mainEntityOfPage":"https://madamedessert.de/schokoladenpudding-rezept-mit-echter-schokolade/#webpage"}]}
diff --git a/cookbook/tests/other/test_data/marmiton.html b/cookbook/tests/other/test_data/marmiton.html
new file mode 100644
index 00000000..fe45c226
--- /dev/null
+++ b/cookbook/tests/other/test_data/marmiton.html
@@ -0,0 +1,2267 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fricassée d'agneau à l'oseille : Recette de Fricassée d'agneau à l'oseille - Marmiton
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dans une poêle, faire sauter l'agneau coupé en gros dés avec l'huile et le beurre. Le laisser colorer et assaisonner.
+
+
+
+ Étape 2
+
+
+
Réserver la viande au chaud et la remplacer par les oignons émincés et la farine. Les faire revenir jusqu'à coloration et mouiller avec le bouillon. Assaisonner et ajouter l'oseille.
+
+
+
+ Étape 3
+
+
+
Replacer les dés d'agneau et laisser cuire à feu doux, à couvert pendant 30 min.
+
+
+
+ Étape 4
+
+
+
Au moment de servir, mettre les morceaux de viande dans le plat de service.
+
+
+
+ Étape 5
+
+
+
Incorporer très vite le jaune d'oeuf et napper la viande de sauce.
C'est la meilleure manière de ne rater aucun numéro, de faire des économies et de se régaler tous les deux mois :) En plus vous aurez accès à la version numérique pour lire vraiment partout.
+ VOIR LES SUPER OFFRES
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cookbook/tests/other/test_data/tasteofhome.html b/cookbook/tests/other/test_data/tasteofhome.html
new file mode 100644
index 00000000..b61b875b
--- /dev/null
+++ b/cookbook/tests/other/test_data/tasteofhome.html
@@ -0,0 +1,866 @@
+
+Rhubarb Tart Recipe: How to Make It | Taste of Home
The rhubarb flavor in this tart balances nicely with the honey and amaretto. The mascarpone cheese makes it rich and creamy. Sometimes I'll even double the rhubarb for really sumptuous tarts. —Ellen Riley, Murfreesboro, Tennessee
Preheat oven to 400°. Unfold 1 pastry sheet and place on a parchment-lined baking sheet; repeat with remaining pastry sheet. Whisk egg and water; brush over pastries. Using a sharp knife, score a 1-in. border around edges of pastry sheets (do not cut through). With a fork, prick center of pastries. Bake until golden brown, about 15 minutes. With a spatula, press down center portion of pastries, leaving outer edges intact. Remove to wire racks to cool.
Meanwhile, for topping, arrange rhubarb in a single layer in a 13x9-in. baking dish. Combine orange juice, honey and amaretto; pour over rhubarb. Bake at 400° until rhubarb is just tender but still holds its shape, about 10 minutes. Remove with a slotted spoon, reserving cooking liquid; let rhubarb cool. Transfer reserved cooking liquid to a small saucepan; bring to a boil over medium-high heat. Reduce heat; simmer until reduced to 1/2 cup, about 20 minutes. Cool.
For filling, stir together mascarpone cheese, amaretto and honey until smooth. Spread mascarpone mixture over center of each pastry. Top with rhubarb ribs. Brush rhubarb with cooled cooking liquid. Refrigerate leftovers.
Test Kitchen tips
Normally, we'd say you could use frozen rhubarb with equally good results, but in this case you definitely need the fresh, long stalks to achieve this spectacular look.
By scoring around the edge of the pastry before baking it, you'll create a way to make a border after it's baked.
Every editorial product is independently selected, though we may be compensated or receive an affiliate commission if you buy something through our links.
*The % Daily Value (DV) tells you how much a nutrient in a food serving contributes to a daily diet. 2,000 calories a day is used for general nutrition advice.
+
+
+
+
+
+
(Nutrition information is calculated using an ingredient database and should be considered an estimate.)
+
+
+
+
+Potato soup is a comforting and versatile dish. It can be light enough for a lunch with a sandwich or hearty enough for a main dish along with a salad and crusty bread or biscuits.
+
+
+
+Top this flavorful soup with a tablespoon or two of shredded cheese, sliced green onions, or chopped fresh parsley.
+
+
+
+This version of soup calls for diced cooked ham, but the ham may be replaced with diced and browned smoked sausage or crumbled browned mild Italian sausage or a similar ground sausage. See the tips and variations below the recipe for more ideas.
+
+Optional: cheddar cheese or cheddar-jack blend (shredded)
+
+
+
+
+
+
+
+
+
Steps to Make It
+
+
+
+Note: there are two great methods you can use to create this delicious creamy potato soup: using the stovetop or the slow cooker.
+
+
+
Stovetop Method
+
+
+
+Gather the ingredients.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+In a large saucepan, melt butter over medium-low heat.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Add onion, celery, carrots, and ham.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Cook, stirring frequently until onions are tender, about 5 minutes.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Add the garlic and continue cooking for 1 to 2 minutes longer.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Add vegetable broth, water, and potatoes.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Cover and cook for about 25 minutes, until potatoes are tender.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Whisk flour into the heavy cream until smooth.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Stir into the hot mixture.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Stir in the half-and-half or milk. Taste and add salt and pepper, as desired. Continue cooking until hot.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Using a potato masher or fork, mash the potatoes slightly to thicken; add more milk if the soup is too thick.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Serve the potato soup garnished with parsley, sliced green onions or chives, or a little bit of shredded cheese.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
Slow Cooker Method
+
+
+
+Gather the ingredients.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+In a large saucepan, melt butter over medium-low heat.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Add onion, celery, carrots, and ham.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Cook, stirring frequently until onions are tender, about 5 minutes.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Add the garlic and continue cooking for 1 to 2 minutes longer.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Then transfer the cooked vegetables to the slow cooker and add the broth, water, and potatoes.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Cover and cook on HIGH for about 2 to 3 hours, or until the potatoes are very tender.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Whisk flour into the heavy cream until smooth.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Stir the flour-cream mixture into the slow cooker.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Stir in the half-and-half or milk. Taste and add salt and pepper, as desired. Continue cooking until hot.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Using a potato masher or fork, mash the potatoes slightly to thicken; add more milk if the soup is too thick.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+Serve the potato soup garnished with parsley, sliced green onions or chives, or a little bit of shredded cheese.
+
+
+
+
+
+
+
+The Spruce Eats / Katarina Zunic
+
+
+
+
+
+
+
+Recipe Variations
+
+
+
Add a cup or more of chopped kale, chard leaves, or spinach to the soup along with the other vegetables.
+
Lighten the soup with low-fat or fat-free half-and-half in place of the heavy cream.
+
Omit the diced ham or replace it with 1 or 2 cups of diced sausage or crumbled browned ground sausage, mild or spicy. Or add cooked crumbled bacon before it's done.
+
Add 1 cup of shredded cheddar cheese to the soup along with the milk and cook until the cheese has melted.
2 colheres (sopa) de uvas-passas pretas sem sementes
300 g de bacalhau dessalgado e desfiado
meio pimentão amarelo pequeno picado
1 xícara (chá) de arroz lavado e escorrido
1 sachê de tempero knorr meu arroz extra alho
2 xícaras (chá) de água
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Modo de Preparo
+
+
+
Em uma tigela, misture 1 colher (sopa) de azeite, os tomates, as ervas e as passas. Reserve. Em uma panela média, aqueça o azeite restante em fogo médio e refogue o bacalhau e o pimentão. Junte o arroz e refogue por mais 3 minutos. Acrescente o sachê do tempero Meu Arroz KNORR Extra Alho e refogue rapidamente. Adicione a água. Cozinhe com a panela parcialmente tampada por 10 minutos ou até secar o líquido. Retire do fogo e reserve tampado por 5 minutos. Acrescente, no arroz, a mistura de tomate reservada, mexendo delicadamente. Tampe a panela e reserve por 5 minutos. Sirva em seguida.
+A receita apresentada nesta página foi enviada por
+TudoGostoso
+através
+desta página.
+
+Se você encontrou algum problema com esta receita, seja no texto, foto ou autoria, por favor entre em contato através do e-mail
+[email protected].
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cookbook/tests/other/test_url_import.py b/cookbook/tests/other/test_url_import.py
index 9e11da08..223455ec 100644
--- a/cookbook/tests/other/test_url_import.py
+++ b/cookbook/tests/other/test_url_import.py
@@ -1,28 +1,98 @@
import json
+import os
-from django_scopes import scopes_disabled
+import pytest
-from cookbook.helper.recipe_url_import import get_from_html
+from django.urls import reverse
-# TODO this test is really bad, need to find a better solution, also pytest does not like those paths
-# def test_ld_json():
-# with scopes_disabled():
-# test_list = [
-# {'file': 'resources/websites/ld_json_1.html', 'result_length': 3237},
-# {'file': 'resources/websites/ld_json_2.html', 'result_length': 1525},
-# {'file': 'resources/websites/ld_json_3.html', 'result_length': 1644},
-# {'file': 'resources/websites/ld_json_4.html', 'result_length': 1744},
-# {'file': 'resources/websites/ld_json_itemList.html', 'result_length': 3222},
-# {'file': 'resources/websites/ld_json_multiple.html', 'result_length': 1621},
-# {'file': 'resources/websites/micro_data_1.html', 'result_length': 1094},
-# {'file': 'resources/websites/micro_data_2.html', 'result_length': 1453},
-# {'file': 'resources/websites/micro_data_3.html', 'result_length': 1163},
-# {'file': 'resources/websites/micro_data_4.html', 'result_length': 4411},
-# ]
-#
-# for test in test_list:
-# with open(test['file'], 'rb') as file:
-# print(f'Testing {test["file"]} expecting length {test["result_length"]}')
-# parsed_content = json.loads(get_from_html(file.read(), 'test_url', None).content)
-# assert len(str(parsed_content)) == test['result_length']
-# file.close()
+from ._recipes import (
+ ALLRECIPES, AMERICAS_TEST_KITCHEN, CHEF_KOCH, CHEF_KOCH2, COOKPAD,
+ COOKS_COUNTRY, DELISH, FOOD_NETWORK, GIALLOZAFFERANO, JOURNAL_DES_FEMMES,
+ MADAME_DESSERT, MARMITON, TASTE_OF_HOME, THE_SPRUCE_EATS, TUDOGOSTOSO)
+
+IMPORT_SOURCE_URL = 'api_recipe_from_source'
+DATA_DIR = "cookbook/tests/other/test_data/"
+
+
+# These were chosen arbitrarily from:
+# Top 10 recipe websites listed here https://www.similarweb.com/top-websites/category/food-and-drink/cooking-and-recipes/
+# plus the test that previously existed
+# plus the custom scraper that was created
+# plus any specific defects discovered along the way
+
+
+@pytest.mark.parametrize("arg", [
+ ['a_u', 302],
+ ['g1_s1', 302],
+ ['u1_s1', 400],
+ ['a1_s1', 400],
+])
+def test_import_permission(arg, request):
+ c = request.getfixturevalue(arg[0])
+ assert c.get(reverse(IMPORT_SOURCE_URL)).status_code == arg[1]
+
+
+@pytest.mark.parametrize("arg", [
+ ALLRECIPES,
+ # test of custom scraper ATK
+ AMERICAS_TEST_KITCHEN,
+ CHEF_KOCH,
+ # test for empty ingredient in ingredient_parser
+ CHEF_KOCH2,
+ COOKPAD,
+ # test of custom scraper ATK
+ COOKS_COUNTRY,
+ DELISH,
+ FOOD_NETWORK,
+ GIALLOZAFFERANO,
+ JOURNAL_DES_FEMMES,
+ # example of recipes_scraper in with wildmode
+ # example of json only source
+ MADAME_DESSERT,
+ MARMITON,
+ TASTE_OF_HOME,
+ # example of non-json recipes_scraper
+ THE_SPRUCE_EATS,
+ TUDOGOSTOSO,
+])
+def test_recipe_import(arg, u1_s1):
+ for f in arg['file']:
+ if 'cookbook' in os.getcwd():
+ test_file = os.path.join(os.getcwd(), 'other', 'test_data', f)
+ else:
+ test_file = os.path.join(os.getcwd(), 'cookbook', 'tests', 'other', 'test_data', f)
+ with open(test_file, 'r', encoding='UTF-8') as d:
+ response = u1_s1.post(
+ reverse(IMPORT_SOURCE_URL),
+ {
+ 'data': d.read(),
+ 'url': arg['url'],
+ 'mode': 'source'
+ },
+ files={'foo': 'bar'}
+ )
+ recipe = json.loads(response.content)['recipe_json']
+ for key in list(set(arg) - set(['file', 'url'])):
+ if type(arg[key]) == list:
+ assert len(recipe[key]) == len(arg[key])
+ if key == 'keywords':
+ valid_keywords = [i['text'] for i in arg[key]]
+ for k in recipe[key]:
+ assert k['text'] in valid_keywords
+ elif key == 'recipeIngredient':
+ valid_ing = ["{:g}{}{}{}{}".format(
+ i['amount'],
+ i['unit']['text'],
+ i['ingredient']['text'],
+ i['note'],
+ i['original'])
+ for i in arg[key]]
+ for i in recipe[key]:
+ assert "{:g}{}{}{}{}".format(
+ i['amount'],
+ i['unit']['text'],
+ i['ingredient']['text'],
+ i['note'],
+ i['original']) in valid_ing
+ else:
+ assert recipe[key] == arg[key]
diff --git a/cookbook/urls.py b/cookbook/urls.py
index bbe97dac..1b056043 100644
--- a/cookbook/urls.py
+++ b/cookbook/urls.py
@@ -35,15 +35,22 @@ router.register(r'cook-log', api.CookLogViewSet)
router.register(r'recipe-book', api.RecipeBookViewSet)
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
router.register(r'supermarket', api.SupermarketViewSet)
+router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
router.register(r'import-log', api.ImportLogViewSet)
+router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
+router.register(r'user-file', api.UserFileViewSet)
urlpatterns = [
path('', views.index, name='index'),
path('setup/', views.setup, name='view_setup'),
+ path('space/', views.space, name='view_space'),
+ path('space/member///', views.space_change_member,
+ name='change_space_member'),
path('no-group', views.no_groups, name='view_no_group'),
path('no-space', views.no_space, name='view_no_space'),
path('no-perm', views.no_perm, name='view_no_perm'),
- path('signup/', views.signup, name='view_signup'),
+ path('signup/', views.signup, name='view_signup'), # TODO deprecated with 0.16.2 remove at some point
+ path('invite/', views.invite_link, name='view_invite'),
path('system/', views.system, name='view_system'),
path('search/', views.search, name='view_search'),
path('search/v2/', views.search_v2, name='view_search_v2'),
@@ -55,6 +62,8 @@ urlpatterns = [
path('shopping/latest/', views.latest_shopping_list, name='view_shopping_latest'),
path('settings/', views.user_settings, name='view_settings'),
path('history/', views.history, name='view_history'),
+ path('supermarket/', views.supermarket, name='view_supermarket'),
+ path('files/', views.files, name='view_files'),
path('test/', views.test, name='view_test'),
path('test2/', views.test2, name='view_test2'),
@@ -93,8 +102,8 @@ urlpatterns = [
path('api/sync_all/', api.sync_all, name='api_sync'),
path('api/log_cooking//', api.log_cooking, name='api_log_cooking'),
path('api/plan-ical///', api.get_plan_ical, name='api_get_plan_ical'),
- path('api/recipe-from-url/', api.recipe_from_url, name='api_recipe_from_url'),
- path('api/recipe-from-json/', api.recipe_from_json, name='api_recipe_from_json'),
+ path('api/recipe-from-source/', api.recipe_from_source, name='api_recipe_from_source'),
+ path('api/backup/', api.get_backup, name='api_backup'),
path('api/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'),
@@ -108,15 +117,18 @@ urlpatterns = [
path('docs/markdown/', views.markdown_info, name='docs_markdown'),
path('docs/api/', views.api_info, name='docs_api'),
- path('openapi/', get_schema_view(title="Django Recipes", version=VERSION_NUMBER, public=True, permission_classes=(permissions.AllowAny,)), name='openapi-schema'),
+ path('openapi/', get_schema_view(title="Django Recipes", version=VERSION_NUMBER, public=True,
+ permission_classes=(permissions.AllowAny,)), name='openapi-schema'),
path('api/', include((router.urls, 'api'))),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
path('offline/', views.offline, name='view_offline'),
- path('service-worker.js', (TemplateView.as_view(template_name="sw.js", content_type='application/javascript', )), name='service_worker'),
- path('manifest.json', (TemplateView.as_view(template_name="manifest.json", content_type='application/json', )), name='web_manifest'),
+ path('service-worker.js', (TemplateView.as_view(template_name="sw.js", content_type='application/javascript', )),
+ name='service_worker'),
+ path('manifest.json', (TemplateView.as_view(template_name="manifest.json", content_type='application/json', )),
+ name='web_manifest'),
]
generic_models = (
diff --git a/cookbook/views/api.py b/cookbook/views/api.py
index bf934d68..96a38b1f 100644
--- a/cookbook/views/api.py
+++ b/cookbook/views/api.py
@@ -31,13 +31,15 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest,
CustomIsOwner, CustomIsShare,
CustomIsShared, CustomIsUser,
group_required)
+from cookbook.helper.recipe_html_import import get_recipe_from_source
+
from cookbook.helper.recipe_search import search_recipes
-from cookbook.helper.recipe_url_import import get_from_html, get_from_scraper, find_recipe_json
+from cookbook.helper.recipe_url_import import get_from_scraper
from cookbook.models import (CookLog, Food, Ingredient, Keyword, MealPlan,
MealType, Recipe, RecipeBook, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Step,
Storage, Sync, SyncLog, Unit, UserPreference,
- ViewLog, RecipeBookEntry, Supermarket, ImportLog)
+ ViewLog, RecipeBookEntry, Supermarket, ImportLog, BookmarkletImport, SupermarketCategory, UserFile)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
@@ -53,8 +55,8 @@ from cookbook.serializer import (FoodSerializer, IngredientSerializer,
SyncSerializer, UnitSerializer,
UserNameSerializer, UserPreferenceSerializer,
ViewLogSerializer, CookLogSerializer, RecipeBookEntrySerializer,
- RecipeOverviewSerializer, SupermarketSerializer, ImportLogSerializer)
-from recipes.settings import DEMO
+ RecipeOverviewSerializer, SupermarketSerializer, ImportLogSerializer,
+ BookmarkletImportSerializer, SupermarketCategorySerializer, UserFileSerializer)
class StandardFilterMixin(ViewSetMixin):
@@ -155,6 +157,16 @@ class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin):
return super().get_queryset()
+class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
+ queryset = SupermarketCategory.objects
+ serializer_class = SupermarketCategorySerializer
+ permission_classes = [CustomIsUser]
+
+ def get_queryset(self):
+ self.queryset = self.queryset.filter(space=self.request.space)
+ return super().get_queryset()
+
+
class KeywordViewSet(viewsets.ModelViewSet, StandardFilterMixin):
"""
list:
@@ -227,8 +239,8 @@ class MealPlanViewSet(viewsets.ModelViewSet):
def get_queryset(self):
queryset = self.queryset.filter(
- Q(created_by=self.request.user) |
- Q(shared=self.request.user)
+ Q(created_by=self.request.user)
+ | Q(shared=self.request.user)
).filter(space=self.request.space).distinct().all()
from_date = self.request.query_params.get('from_date', None)
@@ -285,7 +297,7 @@ class RecipeSchema(AutoSchema):
def get_path_parameters(self, path, method):
if not is_list_view(path, method, self.view):
- return []
+ return super(RecipeSchema, self).get_path_parameters(path, method)
parameters = super().get_path_parameters(path, method)
parameters.append({
@@ -351,7 +363,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space)
- self.queryset = search_recipes(self.queryset, self.request.GET)
+ self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
return super().get_queryset()
@@ -377,7 +389,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
obj, data=request.data, partial=True
)
- if DEMO:
+ if self.request.space.demo:
raise PermissionDenied(detail='Not available in demo', code=None)
if serializer.is_valid():
@@ -474,6 +486,27 @@ class ImportLogViewSet(viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space).all()
+class BookmarkletImportViewSet(viewsets.ModelViewSet):
+ queryset = BookmarkletImport.objects
+ serializer_class = BookmarkletImportSerializer
+ permission_classes = [CustomIsUser]
+
+ def get_queryset(self):
+ return self.queryset.filter(space=self.request.space).all()
+
+
+
+class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin):
+ queryset = UserFile.objects
+ serializer_class = UserFileSerializer
+ permission_classes = [CustomIsUser]
+ parser_classes = [MultiPartParser]
+
+ def get_queryset(self):
+ self.queryset = self.queryset.filter(space=self.request.space).all()
+ return super().get_queryset()
+
+
# -------------- non django rest api views --------------------
def get_recipe_provider(recipe):
@@ -515,7 +548,7 @@ def get_recipe_file(request, recipe_id):
@group_required('user')
def sync_all(request):
- if DEMO:
+ if request.space.demo:
messages.add_message(
request, messages.ERROR, _('This feature is not available in the demo version!')
)
@@ -599,85 +632,91 @@ def get_plan_ical(request, from_date, to_date):
@group_required('user')
-def recipe_from_url(request):
- url = request.POST['url']
+def recipe_from_source(request):
+ url = request.POST.get('url', None)
+ data = request.POST.get('data', None)
+ mode = request.POST.get('mode', None)
+ auto = request.POST.get('auto', 'true')
- try:
- scrape = scrape_me(url)
- except WebsiteNotImplementedError:
+ HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.7) Gecko/2009021910 Firefox/3.0.7"
+ }
+
+ if (not url and not data) or (mode == 'url' and not url) or (mode == 'source' and not data):
+ return JsonResponse(
+ {
+ 'error': True,
+ 'msg': _('Nothing to do.')
+ },
+ status=400
+ )
+
+ if mode == 'url' and auto == 'true':
try:
- scrape = scrape_me(url, wild_mode=True)
- except NoSchemaFoundInWildMode:
+ scrape = scrape_me(url)
+ except (WebsiteNotImplementedError, AttributeError):
+ try:
+ scrape = scrape_me(url, wild_mode=True)
+ except NoSchemaFoundInWildMode:
+ return JsonResponse(
+ {
+ 'error': True,
+ 'msg': _('The requested site provided malformed data and cannot be read.') # noqa: E501
+ },
+ status=400)
+ except ConnectionError:
return JsonResponse(
{
'error': True,
- 'msg': _('The requested site provided malformed data and cannot be read.') # noqa: E501
+ 'msg': _('The requested page could not be found.')
+ },
+ status=400
+ )
+ if len(scrape.ingredients()) and len(scrape.instructions()) == 0:
+ return JsonResponse(
+ {
+ 'error': True,
+ 'msg': _(
+ 'The requested site does not provide any recognized data format to import the recipe from.')
+ # noqa: E501
},
status=400)
- except ConnectionError:
+ else:
+ return JsonResponse({"recipe_json": get_from_scraper(scrape, request.space)})
+ elif (mode == 'source') or (mode == 'url' and auto == 'false'):
+ if not data or data == 'undefined':
+ data = requests.get(url, headers=HEADERS).content
+ recipe_json, recipe_tree, recipe_html, images = get_recipe_from_source(data, url, request.space)
+ if len(recipe_tree) == 0 and len(recipe_json) == 0:
+ return JsonResponse(
+ {
+ 'error': True,
+ 'msg': _('No useable data could be found.')
+ },
+ status=400
+ )
+ else:
+ return JsonResponse({
+ 'recipe_tree': recipe_tree,
+ 'recipe_json': recipe_json,
+ 'recipe_html': recipe_html,
+ 'images': images,
+ })
+
+ else:
return JsonResponse(
{
'error': True,
- 'msg': _('The requested page could not be found.')
- },
- status=400
- )
- return JsonResponse(get_from_scraper(scrape, request.space))
-
-
-@group_required('user')
-def recipe_from_url_old(request):
- url = request.POST['url']
-
- headers = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36'
- # noqa: E501
- }
- try:
- response = requests.get(url, headers=headers)
- except requests.exceptions.ConnectionError:
- return JsonResponse(
- {
- 'error': True,
- 'msg': _('The requested page could not be found.')
+ 'msg': _('I couldn\'t find anything to do.')
},
status=400
)
- if response.status_code == 403:
- return JsonResponse(
- {
- 'error': True,
- 'msg': _('The requested page refused to provide any information (Status Code 403).') # noqa: E501
- },
- status=400
- )
- return get_from_html(response.text, url, request.space)
-
-@group_required('user')
-def recipe_from_json(request):
- mjson = request.POST['json']
-
- md_json = json.loads(mjson)
- for ld_json_item in md_json:
- # recipes type might be wrapped in @graph type
- if '@graph' in ld_json_item:
- for x in md_json['@graph']:
- if '@type' in x and x['@type'] == 'Recipe':
- md_json = x
-
- if ('@type' in md_json
- and md_json['@type'] == 'Recipe'):
- return JsonResponse(find_recipe_json(md_json, '', request.space))
-
- return JsonResponse(
- {
- 'error': True,
- 'msg': _('Could not parse correctly...')
- },
- status=400
- )
+@group_required('admin')
+def get_backup(request):
+ if not request.user.is_superuser:
+ return HttpResponse('', status=403)
@group_required('user')
@@ -690,6 +729,7 @@ def ingredient_from_string(request):
'amount': amount,
'unit': unit,
'food': food,
+ 'note': note
},
status=200
)
diff --git a/cookbook/views/data.py b/cookbook/views/data.py
index e2a862d2..52c2fb4e 100644
--- a/cookbook/views/data.py
+++ b/cookbook/views/data.py
@@ -17,15 +17,23 @@ from PIL import Image, UnidentifiedImageError
from requests.exceptions import MissingSchema
from cookbook.forms import BatchEditForm, SyncForm
-from cookbook.helper.permission_helper import (group_required,
- has_group_permission)
+from cookbook.helper.permission_helper import group_required, has_group_permission
+from cookbook.helper.recipe_url_import import parse_cooktime
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe,
- RecipeImport, Step, Sync, Unit)
+ RecipeImport, Step, Sync, Unit, UserPreference)
from cookbook.tables import SyncTable
@group_required('user')
def sync(request):
+ if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
+ messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
+ return HttpResponseRedirect(reverse('index'))
+
+ if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
+ messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
+ return HttpResponseRedirect(reverse('index'))
+
if request.method == "POST":
if not has_group_permission(request.user, ['admin']):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
@@ -109,8 +117,18 @@ def batch_edit(request):
@group_required('user')
@atomic
def import_url(request):
+ if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
+ messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
+ return HttpResponseRedirect(reverse('index'))
+
+ if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
+ messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
+ return HttpResponseRedirect(reverse('index'))
+
if request.method == 'POST':
data = json.loads(request.body)
+ data['cookTime'] = parse_cooktime(data.get('cookTime', ''))
+ data['prepTime'] = parse_cooktime(data.get('prepTime', ''))
recipe = Recipe.objects.create(
name=data['name'],
@@ -130,7 +148,7 @@ def import_url(request):
recipe.steps.add(step)
for kw in data['keywords']:
- if kw['id'] != "null" and (k := Keyword.objects.filter(id=kw['id'], space=request.space).first()):
+ if k := Keyword.objects.filter(name=kw['text'], space=request.space).first():
recipe.keywords.add(k)
elif data['all_keywords']:
k = Keyword.objects.create(name=kw['text'], space=request.space)
@@ -141,12 +159,12 @@ def import_url(request):
if ing['ingredient']['text'] != '':
ingredient.food, f_created = Food.objects.get_or_create(
- name=ing['ingredient']['text'], space=request.space
+ name=ing['ingredient']['text'].strip(), space=request.space
)
if ing['unit'] and ing['unit']['text'] != '':
ingredient.unit, u_created = Unit.objects.get_or_create(
- name=ing['unit']['text'], space=request.space
+ name=ing['unit']['text'].strip(), space=request.space
)
# TODO properly handle no_amount recipes
@@ -159,7 +177,7 @@ def import_url(request):
elif isinstance(ing['amount'], float) \
or isinstance(ing['amount'], int):
ingredient.amount = ing['amount']
- ingredient.note = ing['note'] if 'note' in ing else ''
+ ingredient.note = ing['note'].strip() if 'note' in ing else ''
ingredient.save()
step.ingredients.add(ingredient)
@@ -189,7 +207,12 @@ def import_url(request):
return HttpResponse(reverse('view_recipe', args=[recipe.pk]))
- return render(request, 'url_import.html', {})
+ if 'id' in request.GET:
+ context = {'bookmarklet': request.GET.get('id', '')}
+ else:
+ context = {}
+
+ return render(request, 'url_import.html', context)
class Object(object):
diff --git a/cookbook/views/edit.py b/cookbook/views/edit.py
index fd127800..241ea960 100644
--- a/cookbook/views/edit.py
+++ b/cookbook/views/edit.py
@@ -1,6 +1,7 @@
import os
from django.contrib import messages
+from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse, reverse_lazy
@@ -18,7 +19,7 @@ from cookbook.helper.permission_helper import (GroupRequiredMixin,
group_required)
from cookbook.models import (Comment, Food, Ingredient, Keyword, MealPlan,
MealType, Recipe, RecipeBook, RecipeImport,
- Storage, Sync)
+ Storage, Sync, UserPreference)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
@@ -45,6 +46,14 @@ def convert_recipe(request, pk):
@group_required('user')
def internal_recipe_update(request, pk):
+ if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() > request.space.max_recipes: # TODO move to central helper function
+ messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
+ return HttpResponseRedirect(reverse('view_recipe', args=[pk]))
+
+ if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
+ messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
+ return HttpResponseRedirect(reverse('view_recipe', args=[pk]))
+
recipe_instance = get_object_or_404(Recipe, pk=pk, space=request.space)
return render(
@@ -279,14 +288,15 @@ def edit_ingredients(request):
new_unit = units_form.cleaned_data['new_unit']
old_unit = units_form.cleaned_data['old_unit']
if new_unit != old_unit:
- recipe_ingredients = Ingredient.objects.filter(unit=old_unit, step__recipe__space=request.space).all()
- for i in recipe_ingredients:
- i.unit = new_unit
- i.save()
+ with scopes_disabled():
+ recipe_ingredients = Ingredient.objects.filter(unit=old_unit).filter(Q(step__recipe__space=request.space) | Q(step__recipe__isnull=True)).all()
+ for i in recipe_ingredients:
+ i.unit = new_unit
+ i.save()
- old_unit.delete()
- success = True
- messages.add_message(request, messages.SUCCESS, _('Units merged!'))
+ old_unit.delete()
+ success = True
+ messages.add_message(request, messages.SUCCESS, _('Units merged!'))
else:
messages.add_message(request, messages.ERROR, _('Cannot merge with the same object!'))
@@ -295,7 +305,7 @@ def edit_ingredients(request):
new_food = food_form.cleaned_data['new_food']
old_food = food_form.cleaned_data['old_food']
if new_food != old_food:
- ingredients = Ingredient.objects.filter(food=old_food, step__recipe__space=request.space).all()
+ ingredients = Ingredient.objects.filter(food=old_food).filter(Q(step__recipe__space=request.space) | Q(step__recipe__isnull=True)).all()
for i in ingredients:
i.food = new_food
i.save()
diff --git a/cookbook/views/import_export.py b/cookbook/views/import_export.py
index 36717441..8c907071 100644
--- a/cookbook/views/import_export.py
+++ b/cookbook/views/import_export.py
@@ -3,7 +3,7 @@ import threading
from io import BytesIO
from django.contrib import messages
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils.translation import gettext as _
@@ -18,12 +18,14 @@ from cookbook.integration.domestica import Domestica
from cookbook.integration.mealie import Mealie
from cookbook.integration.mealmaster import MealMaster
from cookbook.integration.nextcloud_cookbook import NextcloudCookbook
+from cookbook.integration.openeats import OpenEats
from cookbook.integration.paprika import Paprika
from cookbook.integration.recipekeeper import RecipeKeeper
+from cookbook.integration.recettetek import RecetteTek
from cookbook.integration.recipesage import RecipeSage
from cookbook.integration.rezkonv import RezKonv
from cookbook.integration.safron import Safron
-from cookbook.models import Recipe, ImportLog
+from cookbook.models import Recipe, ImportLog, UserPreference
def get_integration(request, export_type):
@@ -47,16 +49,28 @@ def get_integration(request, export_type):
return Domestica(request, export_type)
if export_type == ImportExportBase.RECIPEKEEPER:
return RecipeKeeper(request, export_type)
+ if export_type == ImportExportBase.RECETTETEK:
+ return RecetteTek(request, export_type)
if export_type == ImportExportBase.RECIPESAGE:
return RecipeSage(request, export_type)
if export_type == ImportExportBase.REZKONV:
return RezKonv(request, export_type)
if export_type == ImportExportBase.MEALMASTER:
return MealMaster(request, export_type)
+ if export_type == ImportExportBase.OPENEATS:
+ return OpenEats(request, export_type)
@group_required('user')
def import_recipe(request):
+ if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
+ messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
+ return HttpResponseRedirect(reverse('index'))
+
+ if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
+ messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
+ return HttpResponseRedirect(reverse('index'))
+
if request.method == "POST":
form = ImportForm(request.POST, request.FILES)
if form.is_valid():
@@ -71,9 +85,15 @@ def import_recipe(request):
t.setDaemon(True)
t.start()
- return HttpResponseRedirect(reverse('view_import_response', args=[il.pk]))
+ return JsonResponse({'import_id': [il.pk]})
except NotImplementedError:
- messages.add_message(request, messages.ERROR, _('Importing is not implemented for this provider'))
+ return JsonResponse(
+ {
+ 'error': True,
+ 'msg': _('Importing is not implemented for this provider')
+ },
+ status=400
+ )
else:
form = ImportForm()
diff --git a/cookbook/views/lists.py b/cookbook/views/lists.py
index 433aa980..86bc4182 100644
--- a/cookbook/views/lists.py
+++ b/cookbook/views/lists.py
@@ -30,7 +30,7 @@ def keyword(request):
@group_required('admin')
def sync_log(request):
table = ImportLogTable(
- SyncLog.objects.filter(space=request.space).all().order_by('-created_at')
+ SyncLog.objects.filter(sync__space=request.space).all().order_by('-created_at')
)
RequestConfig(request, paginate={'per_page': 25}).configure(table)
diff --git a/cookbook/views/new.py b/cookbook/views/new.py
index f9c79013..46aa8b32 100644
--- a/cookbook/views/new.py
+++ b/cookbook/views/new.py
@@ -1,7 +1,11 @@
import re
-from datetime import datetime
+from datetime import datetime, timedelta
+from html import escape
+from smtplib import SMTPException
from django.contrib import messages
+from django.contrib.auth.models import Group
+from django.core.mail import send_mail, BadHeaderError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
@@ -13,7 +17,7 @@ from cookbook.forms import (ImportRecipeForm, InviteLinkForm, KeywordForm,
from cookbook.helper.permission_helper import (GroupRequiredMixin,
group_required)
from cookbook.models import (InviteLink, Keyword, MealPlan, MealType, Recipe,
- RecipeBook, RecipeImport, ShareLink, Step)
+ RecipeBook, RecipeImport, ShareLink, Step, UserPreference)
from cookbook.views.edit import SpaceFormMixing
@@ -24,6 +28,14 @@ class RecipeCreate(GroupRequiredMixin, CreateView):
fields = ('name',)
def form_valid(self, form):
+ if self.request.space.max_recipes != 0 and Recipe.objects.filter(space=self.request.space).count() >= self.request.space.max_recipes: # TODO move to central helper function
+ messages.add_message(self.request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
+ return HttpResponseRedirect(reverse('index'))
+
+ if self.request.space.max_users != 0 and UserPreference.objects.filter(space=self.request.space).count() > self.request.space.max_users:
+ messages.add_message(self.request, messages.WARNING, _('You have more users than allowed in your space.'))
+ return HttpResponseRedirect(reverse('index'))
+
obj = form.save(commit=False)
obj.created_by = self.request.user
obj.space = self.request.space
@@ -154,7 +166,8 @@ class MealPlanCreate(GroupRequiredMixin, CreateView, SpaceFormMixing):
def get_form(self, form_class=None):
form = self.form_class(**self.get_form_kwargs())
- form.fields['meal_type'].queryset = MealType.objects.filter(created_by=self.request.user, space=self.request.space).all()
+ form.fields['meal_type'].queryset = MealType.objects.filter(created_by=self.request.user,
+ space=self.request.space).all()
return form
def get_initial(self):
@@ -207,7 +220,32 @@ class InviteLinkCreate(GroupRequiredMixin, CreateView):
obj.created_by = self.request.user
obj.space = self.request.space
obj.save()
- return HttpResponseRedirect(reverse('list_invite_link'))
+ if obj.email:
+ try:
+ if InviteLink.objects.filter(space=self.request.space, created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
+ message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(self.request.user.username)
+ message += _(' to join their Tandoor Recipes space ') + escape(self.request.space.name) + '.\n\n'
+ message += _('Click the following link to activate your account: ') + self.request.build_absolute_uri(reverse('view_invite', args=[str(obj.uuid)])) + '\n\n'
+ message += _('If the link does not work use the following code to manually join the space: ') + str(obj.uuid) + '\n\n'
+ message += _('The invitation is valid until ') + str(obj.valid_until) + '\n\n'
+ message += _('Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/'
+
+ send_mail(
+ _('Tandoor Recipes Invite'),
+ message,
+ None,
+ [obj.email],
+ fail_silently=False,
+ )
+ messages.add_message(self.request, messages.SUCCESS,
+ _('Invite link successfully send to user.'))
+ else:
+ messages.add_message(self.request, messages.ERROR,
+ _('You have send to many emails, please share the link manually or wait a few hours.'))
+ except (SMTPException, BadHeaderError, TimeoutError):
+ messages.add_message(self.request, messages.ERROR, _('Email to user could not be send, please share link manually.'))
+
+ return HttpResponseRedirect(reverse('view_space'))
def get_context_data(self, **kwargs):
context = super(InviteLinkCreate, self).get_context_data(**kwargs)
@@ -218,3 +256,9 @@ class InviteLinkCreate(GroupRequiredMixin, CreateView):
kwargs = super().get_form_kwargs()
kwargs.update({'user': self.request.user})
return kwargs
+
+ def get_initial(self):
+ return dict(
+ space=self.request.space,
+ group=Group.objects.get(name='user')
+ )
diff --git a/cookbook/views/views.py b/cookbook/views/views.py
index d9e2c07e..350ff887 100644
--- a/cookbook/views/views.py
+++ b/cookbook/views/views.py
@@ -3,15 +3,17 @@ import re
from datetime import datetime
from uuid import UUID
+from allauth.account.forms import SignupForm
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
+from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import Group
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import IntegrityError
-from django.db.models import Avg, Q
+from django.db.models import Avg, Q, Sum
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render, redirect
from django.urls import reverse, reverse_lazy
@@ -24,13 +26,14 @@ from rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter
from cookbook.forms import (CommentForm, Recipe, RecipeBookEntryForm, User,
UserCreateForm, UserNameForm, UserPreference,
- UserPreferenceForm)
+ UserPreferenceForm, SpaceJoinForm, SpaceCreateForm, AllAuthSignupForm)
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
- RecipeBook, RecipeBookEntry, ViewLog, ShoppingList, Space)
+ RecipeBook, RecipeBookEntry, ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit,
+ Food, UserFile)
from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall,
- ViewLogTable)
-from recipes.settings import DEMO
+ ViewLogTable, InviteLinkTable)
+from cookbook.views.data import Object
from recipes.version import BUILD_REF, VERSION_NUMBER
@@ -99,18 +102,50 @@ def no_groups(request):
return render(request, 'no_groups_info.html')
+@login_required
def no_space(request):
- if settings.SOCIAL_DEFAULT_ACCESS:
- request.user.userpreference.space = Space.objects.first()
- request.user.userpreference.save()
- request.user.groups.add(Group.objects.get(name=settings.SOCIAL_DEFAULT_GROUP))
+ if request.user.userpreference.space:
return HttpResponseRedirect(reverse('index'))
- return render(request, 'no_space_info.html')
+
+ if request.POST:
+ create_form = SpaceCreateForm(request.POST, prefix='create')
+ join_form = SpaceJoinForm(request.POST, prefix='join')
+ if create_form.is_valid():
+ created_space = Space.objects.create(
+ name=create_form.cleaned_data['name'],
+ created_by=request.user,
+ allow_files=settings.SPACE_DEFAULT_FILES,
+ max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
+ max_users=settings.SPACE_DEFAULT_MAX_USERS,
+ )
+ request.user.userpreference.space = created_space
+ request.user.userpreference.save()
+ request.user.groups.add(Group.objects.filter(name='admin').get())
+
+ messages.add_message(request, messages.SUCCESS, _('You have successfully created your own recipe space. Start by adding some recipes or invite other people to join you.'))
+ return HttpResponseRedirect(reverse('index'))
+
+ if join_form.is_valid():
+ return HttpResponseRedirect(reverse('view_invite', args=[join_form.cleaned_data['token']]))
+ else:
+ if settings.SOCIAL_DEFAULT_ACCESS:
+ request.user.userpreference.space = Space.objects.first()
+ request.user.userpreference.save()
+ request.user.groups.add(Group.objects.get(name=settings.SOCIAL_DEFAULT_GROUP))
+ return HttpResponseRedirect(reverse('index'))
+ if 'signup_token' in request.session:
+ return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')]))
+
+ create_form = SpaceCreateForm()
+ join_form = SpaceJoinForm()
+
+ return render(request, 'no_space_info.html', {'create_form': create_form, 'join_form': join_form})
def no_perm(request):
if not request.user.is_authenticated:
- return HttpResponseRedirect(reverse('index'))
+ messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
+ return HttpResponseRedirect(reverse('account_login') + '?next=' + request.GET.get('next', '/search/'))
return render(request, 'no_perm_info.html')
@@ -196,6 +231,20 @@ def meal_plan(request):
return render(request, 'meal_plan.html', {})
+@group_required('user')
+def supermarket(request):
+ return render(request, 'supermarket.html', {})
+
+
+@group_required('user')
+def files(request):
+ try:
+ current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))['file_size_kb__sum'] / 1000
+ except TypeError:
+ current_file_size_mb = 0
+ return render(request, 'files.html', {'current_file_size_mb': current_file_size_mb, 'max_file_size_mb': request.space.max_file_storage_mb})
+
+
@group_required('user')
def meal_plan_entry(request, pk):
plan = MealPlan.objects.filter(space=request.space).get(pk=pk)
@@ -215,9 +264,7 @@ def meal_plan_entry(request, pk):
@group_required('user')
def latest_shopping_list(request):
- sl = ShoppingList.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).filter(finished=False,
- space=request.space).order_by(
- '-created_at').first()
+ sl = ShoppingList.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).filter(finished=False, pace=request.space).order_by('-created_at').first()
if sl:
return HttpResponseRedirect(reverse('view_shopping', kwargs={'pk': sl.pk}) + '?edit=true')
@@ -227,10 +274,10 @@ def latest_shopping_list(request):
@group_required('user')
def shopping_list(request, pk=None):
- raw_list = request.GET.getlist('r')
+ html_list = request.GET.getlist('r')
recipes = []
- for r in raw_list:
+ for r in html_list:
r = r.replace('[', '').replace(']', '')
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r):
rid, multiplier = r.split(',')
@@ -244,7 +291,7 @@ def shopping_list(request, pk=None):
@group_required('guest')
def user_settings(request):
- if DEMO:
+ if request.space.demo:
messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!'))
return redirect('index')
@@ -274,16 +321,16 @@ def user_settings(request):
up.sticky_navbar = form.cleaned_data['sticky_navbar']
up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync']
- if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: # noqa: E501
- up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL # noqa: E501
+ if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
+ up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
up.save()
if 'user_name_form' in request.POST:
user_name_form = UserNameForm(request.POST, prefix='name')
if user_name_form.is_valid():
- request.user.first_name = user_name_form.cleaned_data['first_name'] # noqa: E501
- request.user.last_name = user_name_form.cleaned_data['last_name'] # noqa: E501
+ request.user.first_name = user_name_form.cleaned_data['first_name']
+ request.user.last_name = user_name_form.cleaned_data['last_name']
request.user.save()
if 'password_form' in request.POST:
@@ -304,7 +351,7 @@ def user_settings(request):
'preference_form': preference_form,
'user_name_form': user_name_form,
'password_form': password_form,
- 'api_token': api_token
+ 'api_token': api_token,
})
@@ -380,7 +427,7 @@ def setup(request):
return render(request, 'setup.html', {'form': form})
-def signup(request, token):
+def invite_link(request, token):
with scopes_disabled():
try:
token = UUID(token, version=4)
@@ -389,44 +436,83 @@ def signup(request, token):
return HttpResponseRedirect(reverse('index'))
if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first():
- if request.method == 'POST':
- updated_request = request.POST.copy()
- if link.username != '':
- updated_request.update({'name': link.username})
+ if request.user.is_authenticated:
+ if request.user.userpreference.space:
+ messages.add_message(request, messages.WARNING, _('You are already member of a space and therefore cannot join this one.'))
+ return HttpResponseRedirect(reverse('index'))
- form = UserCreateForm(updated_request)
+ link.used_by = request.user
+ link.save()
+ request.user.groups.clear()
+ request.user.groups.add(link.group)
- if form.is_valid():
- if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: # noqa: E501
- form.add_error('password', _('Passwords dont match!'))
- else:
- user = User(username=form.cleaned_data['name'], )
- try:
- validate_password(form.cleaned_data['password'], user=user)
- user.set_password(form.cleaned_data['password'])
- user.save()
- messages.add_message(request, messages.SUCCESS, _('User has been created, please login!'))
+ request.user.userpreference.space = link.space
+ request.user.userpreference.save()
- link.used_by = user
- link.save()
- user.groups.add(link.group)
-
- user.userpreference.space = link.space
- user.userpreference.save()
- return HttpResponseRedirect(reverse('account_login'))
- except ValidationError as e:
- for m in e:
- form.add_error('password', m)
+ messages.add_message(request, messages.SUCCESS, _('Successfully joined space.'))
+ return HttpResponseRedirect(reverse('index'))
else:
- form = UserCreateForm()
+ request.session['signup_token'] = str(token)
+ return HttpResponseRedirect(reverse('account_signup'))
- if link.username != '':
- form.fields['name'].initial = link.username
- form.fields['name'].disabled = True
- return render(request, 'account/signup.html', {'form': form, 'link': link})
+ messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
+ return HttpResponseRedirect(reverse('index'))
- messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
- return HttpResponseRedirect(reverse('index'))
+
+# TODO deprecated with 0.16.2 remove at some point
+def signup(request, token):
+ return HttpResponseRedirect(reverse('view_invite', args=[token]))
+
+
+@group_required('admin')
+def space(request):
+ space_users = UserPreference.objects.filter(space=request.space).all()
+
+ counts = Object()
+ counts.recipes = Recipe.objects.filter(space=request.space).count()
+ counts.keywords = Keyword.objects.filter(space=request.space).count()
+ counts.recipe_import = RecipeImport.objects.filter(space=request.space).count()
+ counts.units = Unit.objects.filter(space=request.space).count()
+ counts.ingredients = Food.objects.filter(space=request.space).count()
+ counts.comments = Comment.objects.filter(recipe__space=request.space).count()
+
+ counts.recipes_internal = Recipe.objects.filter(internal=True, space=request.space).count()
+ counts.recipes_external = counts.recipes - counts.recipes_internal
+
+ counts.recipes_no_keyword = Recipe.objects.filter(keywords=None, space=request.space).count()
+
+ invite_links = InviteLinkTable(InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all())
+ RequestConfig(request, paginate={'per_page': 25}).configure(invite_links)
+
+ return render(request, 'space.html', {'space_users': space_users, 'counts': counts, 'invite_links': invite_links})
+
+
+# TODO super hacky and quick solution, safe but needs rework
+# TODO move group settings to space to prevent permissions from one space to move to another
+@group_required('admin')
+def space_change_member(request, user_id, space_id, group):
+ m_space = get_object_or_404(Space, pk=space_id)
+ m_user = get_object_or_404(User, pk=user_id)
+ if request.user == m_space.created_by and m_user != m_space.created_by:
+ if m_user.userpreference.space == m_space:
+ if group == 'admin':
+ m_user.groups.clear()
+ m_user.groups.add(Group.objects.get(name='admin'))
+ return HttpResponseRedirect(reverse('view_space'))
+ if group == 'user':
+ m_user.groups.clear()
+ m_user.groups.add(Group.objects.get(name='user'))
+ return HttpResponseRedirect(reverse('view_space'))
+ if group == 'guest':
+ m_user.groups.clear()
+ m_user.groups.add(Group.objects.get(name='guest'))
+ return HttpResponseRedirect(reverse('view_space'))
+ if group == 'remove':
+ m_user.groups.clear()
+ m_user.userpreference.space = None
+ m_user.userpreference.save()
+ return HttpResponseRedirect(reverse('view_space'))
+ return HttpResponseRedirect(reverse('view_space'))
def markdown_info(request):
diff --git a/docs/features/authentication.md b/docs/features/authentication.md
index 610fb3f4..d4b273fc 100644
--- a/docs/features/authentication.md
+++ b/docs/features/authentication.md
@@ -116,7 +116,7 @@ server {
# Required to allow user to logout of authentication from within Recipes
# Ensure the below is changed to actual the authentication url
location /accounts/logout/ {
- return 301 http:///logout
+ return 301 http:///logout;
}
}
```
diff --git a/docs/features/import_export.md b/docs/features/import_export.md
index c4497010..d3ea5d04 100644
--- a/docs/features/import_export.md
+++ b/docs/features/import_export.md
@@ -34,6 +34,7 @@ Overview of the capabilities of the different integrations.
| Domestica | ✔️ | ⌚ | ✔️ |
| MealMaster | ✔️ | ❌ | ❌ |
| RezKonv | ✔️ | ❌ | ❌ |
+| OpenEats | ✔️ | ❌ | ⌚ |
✔ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
@@ -160,4 +161,45 @@ The RezKonv format is primarily used in the german recipe manager RezKonv Suite.
To migrate from RezKonv Suite to Tandoor select `Export > Gesamtes Kochbuch exportieren` (the last option in the export menu).
The generated file can simply be imported into Tandoor.
-As i only had limited sample data feel free to open an issue if your RezKonv export cannot be imported.
\ No newline at end of file
+As i only had limited sample data feel free to open an issue if your RezKonv export cannot be imported.
+
+## OpenEats
+OpenEats does not provide any way to export the data using the interface. Luckily it is relatively easy to export it from the command line.
+You need to run the command `python manage.py dumpdata recipe ingredient` inside of the application api container.
+If you followed the default installation method you can use the following command `docker-compose -f docker-prod.yml run --rm --entrypoint 'sh' api ./manage.py dumpdata recipe ingredient`.
+
+Store the outputted json string in a `.json` file and simply import it using the importer. The file should look something like this
+```json
+[
+ {
+ "model":"recipe.recipe",
+ "pk":1,
+ "fields":{
+ "title":"Tasty Chili",
+ ...
+ }
+ },
+ ...
+ {
+ "model":"ingredient.ingredientgroup",
+ "pk":1,
+ "fields":{
+ "title":"Veges",
+ "recipe":1
+ }
+ },
+ ...
+ {
+ "model":"ingredient.ingredient",
+ "pk":1,
+ "fields":{
+ "title":"black pepper",
+ "numerator":1.0,
+ "denominator":1.0,
+ "measurement":"dash",
+ "ingredient_group":1
+ }
+ }
+]
+
+```
\ No newline at end of file
diff --git a/docs/install/docker/nginx-proxy/docker-compose.yml b/docs/install/docker/nginx-proxy/docker-compose.yml
index 40b56977..077e57ab 100644
--- a/docs/install/docker/nginx-proxy/docker-compose.yml
+++ b/docs/install/docker/nginx-proxy/docker-compose.yml
@@ -46,5 +46,5 @@ networks:
name: nginx-proxy
volumes:
- nginx:
- staticfiles:
\ No newline at end of file
+ nginx_config:
+ staticfiles:
diff --git a/docs/install/manual.md b/docs/install/manual.md
index 7e5b0fcd..7ebd3cf1 100644
--- a/docs/install/manual.md
+++ b/docs/install/manual.md
@@ -3,7 +3,7 @@
These intructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
!!! warning
- Be sure to use pyton3.8 and pip related to python 3.8. Depending on your distribution calling `python` or `pip` will use python2 instead of pyton 3.8.
+ Be sure to use pyton3.9 and pip related to python 3.9. Depending on your distribution calling `python` or `pip` will use python2 instead of pyton 3.9.
## Prerequisites
@@ -12,7 +12,7 @@ These intructions are inspired from a standard django/gunicorn/postgresql instru
Get the last version from the repository: `git clone https://github.com/vabene1111/recipes.git -b master`
Install postgresql requirements: `sudo apt install libpq-dev postgresql`
-Install project requirements: `pip3.8 install -r requirements.txt`
+Install project requirements: `pip3.9 install -r requirements.txt`
## Setup postgresql
@@ -44,11 +44,11 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template
Execute `export $(cat .env |grep "^[^#]" | xargs)` to load variables from `.env`
-Execute `/python3.8 manage.py migrate`
+Execute `/python3.9 manage.py migrate`
and revert superuser from postgres: `sudo -u postgres psql` and `ALTER USER djangouser WITH NOSUPERUSER;`
-Generate static files: `python3.8 manage.py collectstatic` and remember the folder where files have been copied.
+Generate static files: `python3.9 manage.py collectstatic` and remember the folder where files have been copied.
## Setup web services
@@ -70,7 +70,7 @@ RestartSec=3
Group=www-data
WorkingDirectory=/media/data/recipes
EnvironmentFile=/media/data/recipes/.env
-ExecStart=/opt/.pyenv/versions/3.8.5/bin/gunicorn --error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output --bind unix:/media/data/recipes/recipes.sock recipes.wsgi:application
+ExecStart=/opt/.pyenv/versions/3.9/bin/gunicorn --error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output --bind unix:/media/data/recipes/recipes.sock recipes.wsgi:application
[Install]
WantedBy=multi-user.target
diff --git a/docs/install/other.md b/docs/install/other.md
index 1221b209..4b42eb09 100644
--- a/docs/install/other.md
+++ b/docs/install/other.md
@@ -1,7 +1,7 @@
!!! info "Community Contributed"
The examples in this section were contributed by members of the community.
This page especially contains some setups that might help you if you really want to go down a certain path but none
- of the examples are supported (as i simply am not able to give you support for them).
+ of the examples are supported (as I simply am not able to give you support for them).
## Apache + Traefik + Sub-Path
diff --git a/node_modules/.yarn-integrity b/node_modules/.yarn-integrity
new file mode 100644
index 00000000..93c98326
--- /dev/null
+++ b/node_modules/.yarn-integrity
@@ -0,0 +1,16 @@
+{
+ "systemParams": "win32-x64-83",
+ "modulesFolders": [
+ "node_modules"
+ ],
+ "flags": [],
+ "linkedModules": [],
+ "topLevelPatterns": [
+ "vue-cookies@^1.7.4"
+ ],
+ "lockfileEntries": {
+ "vue-cookies@^1.7.4": "https://registry.yarnpkg.com/vue-cookies/-/vue-cookies-1.7.4.tgz#d241d0a0431da0795837651d10b4d73e7c8d3e8d"
+ },
+ "files": [],
+ "artifacts": {}
+}
\ No newline at end of file
diff --git a/node_modules/vue-cookies/LICENSE b/node_modules/vue-cookies/LICENSE
new file mode 100644
index 00000000..a8fc8514
--- /dev/null
+++ b/node_modules/vue-cookies/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/node_modules/vue-cookies/README.md b/node_modules/vue-cookies/README.md
new file mode 100644
index 00000000..fc52b78e
--- /dev/null
+++ b/node_modules/vue-cookies/README.md
@@ -0,0 +1,223 @@
+# vue-cookies
+
+A simple Vue.js plugin for handling browser cookies
+
+## Installation
+
+### Browser
+```
+
+
+```
+### Package Managers
+```
+npm install vue-cookies --save
+
+// require
+var Vue = require('vue')
+Vue.use(require('vue-cookies'))
+
+// es2015 module
+import Vue from 'vue'
+import VueCookies from 'vue-cookies'
+Vue.use(VueCookies)
+
+// set default config
+Vue.$cookies.config('7d')
+
+// set global cookie
+Vue.$cookies.set('theme','default');
+Vue.$cookies.set('hover-time','1s');
+```
+
+## Api
+
+syntax format: **[this | Vue].$cookies.[method]**
+
+* Set global config
+```
+$cookies.config(expireTimes[,path[, domain[, secure[, sameSite]]]) // default: expireTimes = 1d, path = '/', domain = '', secure = '', sameSite = 'Lax'
+```
+
+* Set a cookie
+```
+$cookies.set(keyName, value[, expireTimes[, path[, domain[, secure[, sameSite]]]]]) //return this
+```
+* Get a cookie
+```
+$cookies.get(keyName) // return value
+```
+* Remove a cookie
+```
+$cookies.remove(keyName [, path [, domain]]) // return this
+```
+* Exist a `cookie name`
+```
+$cookies.isKey(keyName) // return false or true
+```
+* Get All `cookie name`
+```
+$cookies.keys() // return a array
+```
+
+## Example Usage
+
+#### set global config
+```
+// 30 day after, expire
+Vue.$cookies.config('30d')
+
+// set secure, only https works
+Vue.$cookies.config('7d','','',true)
+
+// 2019-03-13 expire
+this.$cookies.config(new Date(2019,03,13).toUTCString())
+
+// 30 day after, expire, '' current path , browser default
+this.$cookies.config(60 * 60 * 24 * 30,'');
+
+```
+
+#### support json object
+```
+var user = { id:1, name:'Journal',session:'25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX' };
+this.$cookies.set('user',user);
+// print user name
+console.log(this.$cookies.get('user').name)
+```
+
+#### set expire times
+**Suppose the current time is : Sat, 11 Mar 2017 12:25:57 GMT**
+
+**Following equivalence: 1 day after, expire**
+
+**Support chaining sets together**
+``` javascript
+ // default expire time: 1 day
+this.$cookies.set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX")
+ // number + d , ignore case
+ .set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX","1d")
+ .set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX","1D")
+ // Base of second
+ .set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX",60 * 60 * 24)
+ // input a Date, + 1day
+ .set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX", new Date(2017, 03, 12))
+ // input a date string, + 1day
+ .set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX", "Sat, 13 Mar 2017 12:25:57 GMT")
+```
+#### set expire times, input number type
+
+```
+this.$cookies.set("default_unit_second","input_value",1); // 1 second after, expire
+this.$cookies.set("default_unit_second","input_value",60 + 30); // 1 minute 30 second after, expire
+this.$cookies.set("default_unit_second","input_value",60 * 60 * 12); // 12 hour after, expire
+this.$cookies.set("default_unit_second","input_value",60 * 60 * 24 * 30); // 1 month after, expire
+```
+
+#### set expire times - end of browser session
+
+```
+this.$cookies.set("default_unit_second","input_value",0); // end of session - use 0 or "0"!
+```
+
+
+#### set expire times , input string type
+
+| Unit | full name |
+| ----------- | ----------- |
+| y | year |
+| m | month |
+| d | day |
+| h | hour |
+| min | minute |
+| s | second |
+
+**Unit Names Ignore Case**
+
+**not support the combination**
+
+**not support the double value**
+
+```javascript
+this.$cookies.set("token","GH1.1.1689020474.1484362313","60s"); // 60 second after, expire
+this.$cookies.set("token","GH1.1.1689020474.1484362313","30MIN"); // 30 minute after, expire, ignore case
+this.$cookies.set("token","GH1.1.1689020474.1484362313","24d"); // 24 day after, expire
+this.$cookies.set("token","GH1.1.1689020474.1484362313","4m"); // 4 month after, expire
+this.$cookies.set("token","GH1.1.1689020474.1484362313","16h"); // 16 hour after, expire
+this.$cookies.set("token","GH1.1.1689020474.1484362313","3y"); // 3 year after, expire
+
+// input date string
+this.$cookies.set('token',"GH1.1.1689020474.1484362313", new Date(2017,3,13).toUTCString());
+this.$cookies.set("token","GH1.1.1689020474.1484362313", "Sat, 13 Mar 2017 12:25:57 GMT ");
+```
+
+#### set expire support date
+```
+var date = new Date;
+date.setDate(date.getDate() + 1);
+this.$cookies.set("token","GH1.1.1689020474.1484362313", date);
+```
+
+#### set never expire
+```
+this.$cookies.set("token","GH1.1.1689020474.1484362313", Infinity); // never expire
+// never expire , only -1,Other negative Numbers are invalid
+this.$cookies.set("token","GH1.1.1689020474.1484362313", -1);
+```
+
+#### remove cookie
+```
+this.$cookies.set("token",value); // domain.com and *.doamin.com are readable
+this.$cookies.remove("token"); // remove token of domain.com and *.doamin.com
+
+this.$cookies.set("token", value, null, null, "domain.com"); // only domain.com are readable
+this.$cookies.remove("token", null, "domain.com"); // remove token of domain.com
+```
+
+#### set other arguments
+```
+// set path
+this.$cookies.set("use_path_argument","value","1d","/app");
+
+// set domain
+this.$cookies.set("use_path_argument","value",null, null, "domain.com"); // default 1 day after,expire
+
+// set secure
+this.$cookies.set("use_path_argument","value",null, null, null,true);
+
+// set sameSite - should be one of `None`, `Strict` or `Lax`. Read more https://web.dev/samesite-cookies-explained/
+this.$cookies.set("use_path_argument","value",null, null, null, null, "Lax");
+```
+
+#### other operation
+```
+// check a cookie exist
+this.$cookies.isKey("token")
+
+// get a cookie
+this.$cookies.get("token");
+
+// remove a cookie
+this.$cookies.remove("token");
+
+// get all cookie key names, line shows
+this.$cookies.keys().join("\n");
+
+// remove all cookie
+this.$cookies.keys().forEach(cookie => this.$cookies.remove(cookie))
+
+// vue-cookies global
+[this | Vue].$cookies.[method]
+
+```
+
+
+## Warning
+
+**$cookies key names Cannot be set to ['expires','max-age','path','domain','secure','SameSite']**
+
+
+## License
+
+[MIT](http://opensource.org/licenses/MIT)
+Copyright (c) 2016-present, cmp-cc
diff --git a/node_modules/vue-cookies/package.json b/node_modules/vue-cookies/package.json
new file mode 100644
index 00000000..2843b6f9
--- /dev/null
+++ b/node_modules/vue-cookies/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "vue-cookies",
+ "version": "1.7.4",
+ "description": "A simple Vue.js plugin for handling browser cookies",
+ "main": "vue-cookies.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/cmp-cc/vue-cookies.git"
+ },
+ "keywords":[
+ "javascript",
+ "vue",
+ "cookie",
+ "cookies",
+ "vue-cookies",
+ "browser",
+ "session"
+ ],
+ "author": "cmp-cc",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/cmp-cc/vue-cookies/issues"
+ },
+ "homepage": "https://github.com/cmp-cc/vue-cookies#readme",
+ "typings": "types/index.d.ts"
+}
diff --git a/node_modules/vue-cookies/sample/welcome.html b/node_modules/vue-cookies/sample/welcome.html
new file mode 100644
index 00000000..ae969d18
--- /dev/null
+++ b/node_modules/vue-cookies/sample/welcome.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+ Welcome Username
+
+
+
+-
+
+
+
+Deomar Santos
+
+em 25/03/13
+
+
+
+
+
+
+
++rápido e delicioso +
+ +